diff --git a/assets/settingsPage.html b/assets/settings-root/settings-root.html similarity index 87% rename from assets/settingsPage.html rename to assets/settings-root/settings-root.html index d507138..8bcc06b 100644 --- a/assets/settingsPage.html +++ b/assets/settings-root/settings-root.html @@ -10,7 +10,7 @@ - + diff --git a/assets/settings-root/settings-root.ts b/assets/settings-root/settings-root.ts new file mode 100644 index 0000000..bc19987 --- /dev/null +++ b/assets/settings-root/settings-root.ts @@ -0,0 +1,199 @@ + +type Hashmap = { [K: string]: T }; +type primitive = "string" | "number" | "boolean"; +type SettingsPageItem = { + level: 0 | 1 | 2, + name: string, + // valueType: string, + // valueOptions?: any[] +}; +type ValueType_function = { + valueType: "function", + // validate: (level: number, upd: ServerConfig) => { valid: boolean, value: any, isChanged: boolean } +} & SettingsPageItem; +type ValueType_primitive = { + valueType: primitive +} & SettingsPageItem; +type ValueType_enum = { + valueType: "enum", + enumType: primitive, + enumOpts: any[] + // valueOptions: ["number" | "string", (number | string)[]] +} & SettingsPageItem; +type ValueType_hashmapenum = { + valueType: "hashmapenum", + enumType: primitive, + enumKeys: string[] +} & SettingsPageItem; +type ValueType_subpage = { + valueType: "subpage", + // handler: (state: StateObject) => void; +} & SettingsPageItem; +type SettingsPageItemTypes = ValueType_function | ValueType_enum | ValueType_hashmapenum | ValueType_primitive | ValueType_subpage; + +interface RootScope extends angular.IScope { + +} + +interface GlobalScope extends RootScope { + +} + + + +//@ts-ignore +let app = angular.module('settings', [ + +]); +app.config(function ($locationProvider) { + $locationProvider.html5Mode(false).hashPrefix('*'); +}); +app.run(function ($templateCache) { + var templates = { + string: ` `, + number: ` `, + boolean: ` `, + enum: ` + +`, + hashmapenum: ` +
+`, + subpage: `Please access this setting at the {{item.name}} subpage.`, + function: ` + + `, + functiontypes: `Coming soon`, + settingsPage: ` +
+{{item.name}} +
+
+ ` + } + + for (var i in templates) { + $templateCache.put("template-" + i, templates[i]); + } +}); +interface HashmapEnumItemCtrlScope extends SettingsPageItemCtrlScope { + key: string; +} +app.controller("HashmapEnumItemCtrl", function ($scope: HashmapEnumItemCtrlScope, $sce: angular.ISCEService) { + let parentItem: SettingsPageItemTypes = $scope.item; + if (parentItem.valueType !== "hashmapenum") return; + $scope.item = { + valueType: parentItem.enumType, + name: $scope.key, + level: parentItem.level + } as SettingsPageItemTypes; + if (typeof $scope.outputs[parentItem.name] !== "object") + $scope.outputs[parentItem.name] = {}; + $scope.outputs = $scope.outputs[parentItem.name]; + $scope.description = $scope.description[$scope.key]; + if (typeof $scope.description === "string") { + $scope.description = $sce.trustAsHtml($scope.description); + } +}); +interface SettingsPageItemCtrlScope extends SettingsPageCtrlScope { + item: SettingsPageItemTypes; + description: string | Hashmap; + readonly: boolean; +} +app.controller("SettingsPageItemCtrl", function ($scope: SettingsPageItemCtrlScope, $sce: angular.ISCEService) { + + $scope.description = $scope.descriptions[$scope.item.name]; + if (typeof $scope.description === "string") { + $scope.description = $sce.trustAsHtml($scope.description); + } + $scope.readonly = $scope.item.level > $scope.level; + +}); +interface SettingsPageCtrlScope extends RootScope { + data: SettingsPageItemTypes[]; + descriptions: Hashmap>; + outputs: Hashmap; + level: number; +} +app.controller("SettingsPageCtrl", function ($scope: SettingsPageCtrlScope, $http: angular.IHttpService) { + let timeout: number; + let saveWait = 1000; + let oldSettings: Hashmap; + function saveSettings() { + let set = {}; + $scope.data.forEach(item => { + let key = item.name; + let newval = JSON.stringify($scope.outputs[key]); + console.log(newval, oldSettings[key]); + if (oldSettings[key] !== newval) { + if (item.level <= $scope.level) + set[key] = $scope.outputs[key]; + oldSettings[key] = newval; + } + }) + $http.put("?action=update", JSON.stringify(set)); + } + + $http.get('?action=getdata').then(res => { + let { level, data, descriptions, settings } = res.data as any; + let timeout; + + $scope.$watch((s: SettingsPageCtrlScope) => JSON.stringify(s.outputs), (item, old) => { + if (!old || item === old) return; + if (timeout) clearTimeout(timeout); + timeout = setTimeout(saveSettings, saveWait); + }); + + $scope.data = data; + $scope.descriptions = descriptions; + $scope.outputs = settings; + $scope.level = level; + oldSettings = {}; + data.forEach(item => { + oldSettings[item.name] = JSON.stringify(settings[item.name]); + }) + $scope.$broadcast('refresh'); + }); +}) + + + + + +// angular.module("settings", [ + +// ]).controller("globalCtrl", function ($scope: GlobalScope, $http: angular.IHttpService) { +// $http.get("?") +// }) + + // const old = { + // "template-children":/* just some green */ ` + //
`, + // "template-repeater":/* just some green */ ` + //
`, + // "template-hashmap-item":/* just some green */ ` + //
  • + // {{j}} + //
      + // {{outputs}} + //
    • `, + // 'template-hashmap':/* just some green */ ` + //
        + // + // + // + // `, + // 'template-text-row':/* just some green */ ` + // {{item.name}} + // + // ` + // } \ No newline at end of file diff --git a/assets/settings-root/tsconfig.json b/assets/settings-root/tsconfig.json new file mode 100644 index 0000000..98cb606 --- /dev/null +++ b/assets/settings-root/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "outDir": "../static/" + } +} \ No newline at end of file diff --git a/assets/settings-tree/settings-tree.html b/assets/settings-tree/settings-tree.html new file mode 100644 index 0000000..9b61de4 --- /dev/null +++ b/assets/settings-tree/settings-tree.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + +

        Hi

        +
        + + + \ No newline at end of file diff --git a/assets/settings-tree/settings-tree.ts b/assets/settings-tree/settings-tree.ts new file mode 100644 index 0000000..23dc02b --- /dev/null +++ b/assets/settings-tree/settings-tree.ts @@ -0,0 +1,132 @@ + +type Hashmap = { [K: string]: T }; +type SettingsPageItem = { + level: 0 | 1 | 2, + name: string, + // valueType: string, + // valueOptions?: any[] +}; +type ValueType_function = { + valueType: "function", + // valueOptions: [(defValue: any, keys: string[], readOnly: boolean, description: any) => string] +} & SettingsPageItem; +type ValueType_primitive = { + valueType: "string" | "number" | "boolean" +} & SettingsPageItem; +type ValueType_enum = { + valueType: "enum", + valueOptions: ["number" | "string", (number | string)[]] +} & SettingsPageItem; +type ValueType_hashmapenum = { + valueType: "hashmapenum", + valueOptions: [("string" | "number" | "boolean")[], string[]] +} & SettingsPageItem; +type ValueType_subpage = { + valueType: "subpage", + valueOptions: {} +} & SettingsPageItem; +type SettingsPageItemTypes = ValueType_function | ValueType_enum | ValueType_hashmapenum | ValueType_primitive | ValueType_subpage; + +interface RootScope extends angular.IScope { + +} + +interface GlobalScope extends RootScope { + +} + + + +//@ts-ignore +let app = angular.module('settings', [ + +]); +app.config(function ($locationProvider) { + $locationProvider.html5Mode(false).hashPrefix('*'); +}); +app.run(function ($templateCache) { + var templates = { + string: ` `, + number: ` `, + boolean: ` `, + enum: ` + +`, + hashmapenum: ` +
        +`, + subpage: `Please access this setting at the {{item.name}} subpage.`, + function: ` + + `, + functiontypes: `Coming soon`, + settingsPage: ` +
        +{{item.name}} +
        +
        + ` + } + + for (var i in templates) { + $templateCache.put("template-" + i, templates[i]); + } +}); +interface HashmapEnumItemCtrlScope extends SettingsPageItemCtrlScope { + key: string; +} +app.controller("HashmapEnumItemCtrl", function ($scope: HashmapEnumItemCtrlScope, $sce: angular.ISCEService) { + let parentItem: SettingsPageItemTypes = $scope.item; + if (parentItem.valueType !== "hashmapenum") return; + $scope.item = { + valueType: parentItem.valueOptions[0][0], + name: $scope.key, + level: parentItem.level + } as SettingsPageItemTypes; + if (typeof $scope.outputs[parentItem.name] !== "object") + $scope.outputs[parentItem.name] = {}; + $scope.outputs = $scope.outputs[parentItem.name]; + $scope.description = $scope.description[$scope.key]; + if (typeof $scope.description === "string") { + $scope.description = $sce.trustAsHtml($scope.description); + } +}); +interface SettingsPageItemCtrlScope extends SettingsPageCtrlScope { + item: SettingsPageItemTypes; + description: string | Hashmap; + readonly: boolean; +} +app.controller("SettingsPageItemCtrl", function ($scope: SettingsPageItemCtrlScope, $sce: angular.ISCEService) { + + $scope.description = $scope.descriptions[$scope.item.name]; + if (typeof $scope.description === "string") { + $scope.description = $sce.trustAsHtml($scope.description); + } + $scope.readonly = $scope.item.level > $scope.level; + +}); +interface SettingsPageCtrlScope extends RootScope { + data: SettingsPageItemTypes[]; + descriptions: Hashmap>; + outputs: Hashmap; + level: number; +} +app.controller("SettingsPageCtrl", function ($scope: SettingsPageCtrlScope, $http: angular.IHttpService) { + $http.get('?action=getdata').then(res => { + let { level, data, descriptions, settings } = res.data as any; + $scope.data = data; + $scope.descriptions = descriptions; + $scope.outputs = settings; + $scope.level = level; + $scope.$broadcast('refresh'); + }); +}) + + + + diff --git a/assets/settings-tree/tsconfig.json b/assets/settings-tree/tsconfig.json new file mode 100644 index 0000000..98cb606 --- /dev/null +++ b/assets/settings-tree/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "outDir": "../static/" + } +} \ No newline at end of file diff --git a/assets/static/settings-page.js b/assets/static/settings-page.js deleted file mode 100644 index c43d79d..0000000 --- a/assets/static/settings-page.js +++ /dev/null @@ -1,176 +0,0 @@ -angular.module('settings', []).config(function ($locationProvider) { - $locationProvider.html5Mode(false).hashPrefix('*'); -}).run(function ($templateCache) { - var templates = { - string: ` `, - number: ` `, - boolean: ` `, - enum: ` - -`, - hashmapenum: ` -
        - -
        -
        `, - subpage: `Please go to {{item.name}} for details on changing this setting`, - function: ` - - `, - functiontypes: `Coming soon`, - settingsPage: ` -
        -{{item.name}} -
        -
        - ` - }; - for (var i in templates) { - $templateCache.put("template-" + i, templates[i]); - } -}).controller("HashmapEnumItemCtrl", function ($scope, $sce) { - let parentItem = $scope.item; - if (parentItem.valueType !== "hashmapenum") - return; - $scope.item = { - valueType: parentItem.valueOptions[0][0], - name: $scope.key, - type: parentItem.type - }; - if (typeof $scope.outputs[parentItem.name] !== "object") - $scope.outputs[parentItem.name] = {}; - $scope.outputs = $scope.outputs[parentItem.name]; - $scope.description = $scope.description[$scope.key]; - if (typeof $scope.description === "string") { - $scope.description = $sce.trustAsHtml($scope.description); - } -}).controller("SettingsPageItemCtrl", function ($scope, $sce) { - $scope.description = $scope.description[$scope.item.name]; - if (typeof $scope.description === "string") { - $scope.description = $sce.trustAsHtml($scope.description); - } -}).controller("SettingsPageCtrl", function ($scope) { - $scope.outputs = { - "tree": { - "ArlenNotes": "C:\\Users\\Arlen\\Dropbox\\ArlenNotes", - "ArlenStorage": "C:\\ArlenNotesStorage", - "ArlenJournal": "G:/", - "dropbox": "C:\\Users\\Arlen\\Dropbox\\TiddlyWiki", - "projects": { - "tw5-angular": "..\\tw5-angular", - "monacotw5": "C:\\ArlenStuff\\TiddlyWiki-Monaco-Editor", - "wavenet": "C:\\Users\\Arlen\\Dropbox\\Projects\\WaveNet", - "tiddlywiki": "C:\\ArlenStuff\\TiddlyWiki5-5.1.14", - "fol": "C:\\Users\\Arlen\\Dropbox\\FOL\\tw5-notes", - "lambda-client": "C:\\ArlenProjects\\aws-tiddlyweb\\lambda-client" - }, - "wef-reports": "c:\\ArlenProjects\\wef-reports", - "tesol": "C:\\Users\\Arlen\\Dropbox\\TESOL", - "tw5-dropbox": "C:\\ArlenStuff\\tw5-dropbox", - "twcloud-dropbox": "C:\\ArlenStuff\\twcloud\\dropbox", - "genyoutube": "C:\\Users\\Arlen\\Music\\GenYoutube", - "sock.pac": "C:\\sock.pac" - }, - "types": { - "htmlfile": [ - "htm", - "html" - ] - }, - "port": 80, - "host": "0.0.0.0", - "backupDirectory": "", - "etag": "", - "etagWindow": 3, - "useTW5path": false, - "allowNetwork": { - "settings": true - } - }; - $scope.data = [ - { type: 2, name: "tree", valueType: "subpage", }, - { type: 0, name: "types", valueType: "function", }, - { type: 1, name: "host", valueType: "string" }, - { type: 1, name: "port", valueType: "number" }, - { type: 1, name: "username", valueType: "string" }, - { type: 1, name: "password", valueType: "string" }, - { type: 0, name: "backupDirectory", valueType: "string" }, - { type: 0, name: "etag", valueType: "enum", valueOptions: ["string", ["", "disabled", "required"]] }, - { type: 0, name: "etagWindow", valueType: "number" }, - { type: 1, name: "useTW5path", valueType: "boolean" }, - { type: 0, name: "debugLevel", valueType: "enum", valueOptions: ["number", [4, 3, 2, 1, 0, -1, -2, -3, -4]] }, - { - type: 1, - name: "allowNetwork", - valueType: "hashmapenum", - valueOptions: [ - ["boolean"], - ["mkdir", "upload", "settings", "WARNING_all_settings_WARNING"] - ] - }, - ]; - $scope.description = { - tree: "The mount structure of the server", - types: "Specifies which extensions get used for each icon", - host: "The IP address to listen on for requests. 0.0.0.0 listens on all IP addresses. " - + "127.0.0.1 only listens on localhost.
        " - + "TECHNICAL: 127.0.0.1 is always bound to even when another IP is specified.", - port: "The port number to listen on.", - username: "The basic auth username to use. Also forwarded to data folders for signing edits.", - password: "The basic auth password to use.", - etag: "disabled (Don't check etags), " - + "required (Require etags to be used), " - + "<not specified> (only check etag if sent by the client)", - etagWindow: "If the etag gets checked, allow a file to be saved if the etag is not stale by more than this many seconds.", - backupDirectory: "The directory to save backup files in from single file wikis. Data folders are not backed up.", - debugLevel: "Print out messages with this debug level or higher. See the readme for more detail.", - useTW5path: "Mount data folders as the directory index (like NodeJS: /mydatafolder/) instead of as a file (like single-file wikis: /mydatafolder). It is recommended to leave this off unless you need it.", - allowNetwork: { - mkdir: "Allow network users to create directories and datafolders.", - upload: "Allow network users to upload files.", - settings: "Allow network users to change non-critical settings.", - WARNING_all_settings_WARNING: "Allow network users to change critical settings: " - + `${$scope.data.filter(e => e.type > 0).map(e => e.name).join(', ')}` - }, - maxAge: "", - tsa: "", - _disableLocalHost: "", - __dirname: "READONLY: Directory of currently loaded settings file", - __assetsDir: "" - }; -}); -// angular.module("settings", [ -// ]).controller("globalCtrl", function ($scope: GlobalScope, $http: angular.IHttpService) { -// $http.get("?") -// }) -// const old = { -// "template-children":/* just some green */ ` -//
        `, -// "template-repeater":/* just some green */ ` -//
        `, -// "template-hashmap-item":/* just some green */ ` -//
      • -// {{j}} -//
          -// {{outputs}} -//
        • `, -// 'template-hashmap':/* just some green */ ` -//
            -// -// -// -// `, -// 'template-text-row':/* just some green */ ` -// {{item.name}} -// -// ` -// } diff --git a/assets/static/settings-page.ts b/assets/static/settings-page.ts deleted file mode 100644 index c38099a..0000000 --- a/assets/static/settings-page.ts +++ /dev/null @@ -1,224 +0,0 @@ -type SettingsPageItem = { - type: 0 | 1 | 2, - name: string, - // valueType: string, - // valueOptions?: any[] -}; -type ValueType = { - valueType: "function", - // valueOptions: [(defValue: any, keys: string[], readOnly: boolean, description: any) => string] -} | { - valueType: "string" | "number" | "boolean" - } | { - valueType: "enum", - valueOptions: ["number" | "string", (number | string)[]] - } | { - valueType: "hashmapenum", - valueOptions: [("string" | "number" | "boolean")[], string[]] - } - -interface RootScope extends angular.IScope { - -} - -interface GlobalScope extends RootScope { - -} - -interface SettingsPageCtrlScope extends RootScope { - -} -interface SettingsPageItemCtrlScope extends SettingsPageCtrlScope { - -} -interface HashmapEnumItemCtrlScope extends SettingsPageCtrlScope { - -} -angular.module('settings', [ - -]).config(function ($locationProvider) { - $locationProvider.html5Mode(false).hashPrefix('*'); -}).run(function ($templateCache) { - var templates = { - string: ` `, - number: ` `, - boolean: ` `, - enum: ` - -`, - hashmapenum: ` -
            - -
            -
            `, - subpage: `Please go to {{item.name}} for details on changing this setting`, - function: ` - - `, - functiontypes: `Coming soon`, - settingsPage: ` -
            -{{item.name}} -
            -
            - ` - } - - for (var i in templates) { - $templateCache.put("template-" + i, templates[i]); - } -}).controller("HashmapEnumItemCtrl", function ($scope, $sce: angular.ISCEService) { - let parentItem: SettingsPageItem & ValueType = $scope.item; - if (parentItem.valueType !== "hashmapenum") return; - $scope.item = { - valueType: parentItem.valueOptions[0][0], - name: $scope.key, - type: parentItem.type - } as SettingsPageItem & ValueType; - if (typeof $scope.outputs[parentItem.name] !== "object") - $scope.outputs[parentItem.name] = {}; - $scope.outputs = $scope.outputs[parentItem.name]; - $scope.description = $scope.description[$scope.key]; - if(typeof $scope.description === "string"){ - $scope.description = $sce.trustAsHtml($scope.description); - } -}).controller("SettingsPageItemCtrl", function ($scope, $sce: angular.ISCEService) { - $scope.description = $scope.description[$scope.item.name]; - if(typeof $scope.description === "string"){ - $scope.description = $sce.trustAsHtml($scope.description); - } -}).controller("SettingsPageCtrl", function ($scope) { - $scope.outputs = { - "tree": { - "ArlenNotes": "C:\\Users\\Arlen\\Dropbox\\ArlenNotes", - "ArlenStorage": "C:\\ArlenNotesStorage", - "ArlenJournal": "G:/", - "dropbox": "C:\\Users\\Arlen\\Dropbox\\TiddlyWiki", - "projects": { - "tw5-angular": "..\\tw5-angular", - "monacotw5": "C:\\ArlenStuff\\TiddlyWiki-Monaco-Editor", - "wavenet": "C:\\Users\\Arlen\\Dropbox\\Projects\\WaveNet", - "tiddlywiki": "C:\\ArlenStuff\\TiddlyWiki5-5.1.14", - "fol": "C:\\Users\\Arlen\\Dropbox\\FOL\\tw5-notes", - "lambda-client": "C:\\ArlenProjects\\aws-tiddlyweb\\lambda-client" - }, - "wef-reports": "c:\\ArlenProjects\\wef-reports", - "tesol": "C:\\Users\\Arlen\\Dropbox\\TESOL", - "tw5-dropbox": "C:\\ArlenStuff\\tw5-dropbox", - "twcloud-dropbox": "C:\\ArlenStuff\\twcloud\\dropbox", - "genyoutube": "C:\\Users\\Arlen\\Music\\GenYoutube", - "sock.pac": "C:\\sock.pac" - }, - "types": { - "htmlfile": [ - "htm", - "html" - ] - }, - "port": 80, - "host": "0.0.0.0", - "backupDirectory": "", - "etag": "", - "etagWindow": 3, - "useTW5path": false, - "allowNetwork": { - "settings": true - } - }; - $scope.data = [ - { type: 2, name: "tree", valueType: "subpage", /* valueOptions: [treeGenerate] */ }, - { type: 0, name: "types", valueType: "function", /* valueOptions: [typesFunction] */ }, - { type: 1, name: "host", valueType: "string" }, - { type: 1, name: "port", valueType: "number" }, - { type: 1, name: "username", valueType: "string" }, - { type: 1, name: "password", valueType: "string" }, - { type: 0, name: "backupDirectory", valueType: "string" }, - { type: 0, name: "etag", valueType: "enum", valueOptions: ["string", ["", "disabled", "required"]] }, - { type: 0, name: "etagWindow", valueType: "number" }, - { type: 1, name: "useTW5path", valueType: "boolean" }, - { type: 0, name: "debugLevel", valueType: "enum", valueOptions: ["number", [4, 3, 2, 1, 0, -1, -2, -3, -4]] }, - { - type: 1, - name: "allowNetwork", - valueType: "hashmapenum", - valueOptions: [ - ["boolean"], - ["mkdir", "upload", "settings", "WARNING_all_settings_WARNING"] - ] - }, - // { type: "disabled", name: "_disableLocalHost" }, - // { type: "disabled", name: "tsa" }, - // { type: "disabled", name: "maxAge" } - ]; - $scope.description = { - tree: "The mount structure of the server", - types: "Specifies which extensions get used for each icon", - host: "The IP address to listen on for requests. 0.0.0.0 listens on all IP addresses. " - + "127.0.0.1 only listens on localhost.
            " - + "TECHNICAL: 127.0.0.1 is always bound to even when another IP is specified.", - port: "The port number to listen on.", - username: "The basic auth username to use. Also forwarded to data folders for signing edits.", - password: "The basic auth password to use.", - etag: "disabled (Don't check etags), " - + "required (Require etags to be used), " - + "<not specified> (only check etag if sent by the client)", - etagWindow: "If the etag gets checked, allow a file to be saved if the etag is not stale by more than this many seconds.", - backupDirectory: "The directory to save backup files in from single file wikis. Data folders are not backed up.", - debugLevel: "Print out messages with this debug level or higher. See the readme for more detail.", - useTW5path: "Mount data folders as the directory index (like NodeJS: /mydatafolder/) instead of as a file (like single-file wikis: /mydatafolder). It is recommended to leave this off unless you need it.", - allowNetwork: { - mkdir: "Allow network users to create directories and datafolders.", - upload: "Allow network users to upload files.", - settings: "Allow network users to change non-critical settings.", - WARNING_all_settings_WARNING: "Allow network users to change critical settings: " - + `${$scope.data.filter(e => e.type > 0).map(e => e.name).join(', ')}` - }, - maxAge: "", - tsa: "", - _disableLocalHost: "", - __dirname: "READONLY: Directory of currently loaded settings file", - __assetsDir: "" - }; -}) - - - - - -// angular.module("settings", [ - -// ]).controller("globalCtrl", function ($scope: GlobalScope, $http: angular.IHttpService) { -// $http.get("?") -// }) - - // const old = { - // "template-children":/* just some green */ ` - //
            `, - // "template-repeater":/* just some green */ ` - //
            `, - // "template-hashmap-item":/* just some green */ ` - //
          • - // {{j}} - //
              - // {{outputs}} - //
            • `, - // 'template-hashmap':/* just some green */ ` - //
                - // - // - // - // `, - // 'template-text-row':/* just some green */ ` - // {{item.name}} - // - // ` - // } \ No newline at end of file diff --git a/assets/static/settings-root.js b/assets/static/settings-root.js new file mode 100644 index 0000000..3a95a69 --- /dev/null +++ b/assets/static/settings-root.js @@ -0,0 +1,114 @@ +//@ts-ignore +var app = angular.module('settings', []); +app.config(function ($locationProvider) { + $locationProvider.html5Mode(false).hashPrefix('*'); +}); +app.run(function ($templateCache) { + var templates = { + string: " ", + number: " ", + boolean: " ", + "enum": "\n\t \n", + hashmapenum: "\n
                \n", + subpage: "Please access this setting at the {{item.name}} subpage.", + "function": "\n\n\t\t", + functiontypes: "Coming soon", + settingsPage: "\n
                \n{{item.name}}\n
                \n
                \n\t\t" + }; + for (var i in templates) { + $templateCache.put("template-" + i, templates[i]); + } +}); +app.controller("HashmapEnumItemCtrl", function ($scope, $sce) { + var parentItem = $scope.item; + if (parentItem.valueType !== "hashmapenum") + return; + $scope.item = { + valueType: parentItem.enumType, + name: $scope.key, + level: parentItem.level + }; + if (typeof $scope.outputs[parentItem.name] !== "object") + $scope.outputs[parentItem.name] = {}; + $scope.outputs = $scope.outputs[parentItem.name]; + $scope.description = $scope.description[$scope.key]; + if (typeof $scope.description === "string") { + $scope.description = $sce.trustAsHtml($scope.description); + } +}); +app.controller("SettingsPageItemCtrl", function ($scope, $sce) { + $scope.description = $scope.descriptions[$scope.item.name]; + if (typeof $scope.description === "string") { + $scope.description = $sce.trustAsHtml($scope.description); + } + $scope.readonly = $scope.item.level > $scope.level; +}); +app.controller("SettingsPageCtrl", function ($scope, $http) { + var timeout; + var saveWait = 1000; + var oldSettings; + function saveSettings() { + var set = {}; + $scope.data.forEach(function (item) { + var key = item.name; + var newval = JSON.stringify($scope.outputs[key]); + console.log(newval, oldSettings[key]); + if (oldSettings[key] !== newval) { + if (item.level <= $scope.level) + set[key] = $scope.outputs[key]; + oldSettings[key] = newval; + } + }); + $http.put("?action=update", JSON.stringify(set)); + } + $http.get('?action=getdata').then(function (res) { + var _a = res.data, level = _a.level, data = _a.data, descriptions = _a.descriptions, settings = _a.settings; + var timeout; + $scope.$watch(function (s) { return JSON.stringify(s.outputs); }, function (item, old) { + if (!old || item === old) + return; + if (timeout) + clearTimeout(timeout); + timeout = setTimeout(saveSettings, saveWait); + }); + $scope.data = data; + $scope.descriptions = descriptions; + $scope.outputs = settings; + $scope.level = level; + oldSettings = {}; + data.forEach(function (item) { + oldSettings[item.name] = JSON.stringify(settings[item.name]); + }); + $scope.$broadcast('refresh'); + }); +}); +// angular.module("settings", [ +// ]).controller("globalCtrl", function ($scope: GlobalScope, $http: angular.IHttpService) { +// $http.get("?") +// }) +// const old = { +// "template-children":/* just some green */ ` +//
                `, +// "template-repeater":/* just some green */ ` +//
                `, +// "template-hashmap-item":/* just some green */ ` +//
              • +// {{j}} +//
                  +// {{outputs}} +//
                • `, +// 'template-hashmap':/* just some green */ ` +//
                    +// +// +// +// `, +// 'template-text-row':/* just some green */ ` +// {{item.name}} +// +// ` +// } diff --git a/assets/static/settings-tree.js b/assets/static/settings-tree.js new file mode 100644 index 0000000..f36b13a --- /dev/null +++ b/assets/static/settings-tree.js @@ -0,0 +1,55 @@ +//@ts-ignore +var app = angular.module('settings', []); +app.config(function ($locationProvider) { + $locationProvider.html5Mode(false).hashPrefix('*'); +}); +app.run(function ($templateCache) { + var templates = { + string: " ", + number: " ", + boolean: " ", + "enum": "\n\t \n", + hashmapenum: "\n
                    \n", + subpage: "Please access this setting at the {{item.name}} subpage.", + "function": "\n\n\t\t", + functiontypes: "Coming soon", + settingsPage: "\n
                    \n{{item.name}}\n
                    \n
                    \n\t\t" + }; + for (var i in templates) { + $templateCache.put("template-" + i, templates[i]); + } +}); +app.controller("HashmapEnumItemCtrl", function ($scope, $sce) { + var parentItem = $scope.item; + if (parentItem.valueType !== "hashmapenum") + return; + $scope.item = { + valueType: parentItem.valueOptions[0][0], + name: $scope.key, + level: parentItem.level + }; + if (typeof $scope.outputs[parentItem.name] !== "object") + $scope.outputs[parentItem.name] = {}; + $scope.outputs = $scope.outputs[parentItem.name]; + $scope.description = $scope.description[$scope.key]; + if (typeof $scope.description === "string") { + $scope.description = $sce.trustAsHtml($scope.description); + } +}); +app.controller("SettingsPageItemCtrl", function ($scope, $sce) { + $scope.description = $scope.descriptions[$scope.item.name]; + if (typeof $scope.description === "string") { + $scope.description = $sce.trustAsHtml($scope.description); + } + $scope.readonly = $scope.item.level > $scope.level; +}); +app.controller("SettingsPageCtrl", function ($scope, $http) { + $http.get('?action=getdata').then(function (res) { + var _a = res.data, level = _a.level, data = _a.data, descriptions = _a.descriptions, settings = _a.settings; + $scope.data = data; + $scope.descriptions = descriptions; + $scope.outputs = settings; + $scope.level = level; + $scope.$broadcast('refresh'); + }); +}); diff --git a/build.bat b/build-nexe.bat similarity index 100% rename from build.bat rename to build-nexe.bat diff --git a/build-source.bat b/build-source.bat new file mode 100644 index 0000000..84a5b82 --- /dev/null +++ b/build-source.bat @@ -0,0 +1,3 @@ +start tsc -p assets/settings-root %* +start tsc -p assets/settings-tree %* +start tsc %* \ No newline at end of file diff --git a/npm-debug.log.2593129481 b/npm-debug.log.2593129481 new file mode 100644 index 0000000..e69de29 diff --git a/src/datafolder.js b/src/datafolder.js index 912f713..89223e2 100644 --- a/src/datafolder.js +++ b/src/datafolder.js @@ -9,7 +9,9 @@ var settings = {}; const debug = server_types_1.DebugLogger('DAT'); const loadedFolders = {}; const otherSocketPaths = {}; -function init(eventer) { +let eventer; +function init(e) { + eventer = e; eventer.on('settings', function (set) { settings = set; }); @@ -151,6 +153,7 @@ function loadTiddlyWiki(mount, folder, reload) { } var command = new serverCommand([], { wiki: $tw.wiki }); var server = command.server; + //If the username is changed the datafolder will just have to be reloaded server.set({ rootTiddler: "$:/core/save/all", renderType: "text/plain", @@ -227,7 +230,6 @@ function DataFolder(mount, folder, callback) { } } let counter = 0; -const zlib_1 = require("zlib"); const boot_startup_1 = require("./boot-startup"); const bundled_lib_1 = require("../lib/bundled-lib"); let pluginCache; @@ -326,7 +328,7 @@ function sendPluginResponse(state, pluginCache) { res.end(); } else { - sendResponse(res, body, { doGzip: acceptGzip(req) }); + server_types_1.sendResponse(res, body, { doGzip: server_types_1.canAcceptGzip(req) }); } } function loadTiddlyServerAdapter(mount, folder, reload, wikiInfo) { @@ -442,7 +444,7 @@ const globalRegex = /\$\{mount\}/g; //just save it here so we don't have to keep reloading it const loaderText = fs.readFileSync(path.join(__dirname, './datafolder-template.html'), 'utf8'); function sendLoader(tsa) { - sendResponse(tsa.state.res, loaderText.replace(globalRegex, tsa.mount), { doGzip: acceptGzip(tsa.state.req), contentType: "text/html; charset=utf-8" }); + server_types_1.sendResponse(tsa.state.res, loaderText.replace(globalRegex, tsa.mount), { doGzip: server_types_1.canAcceptGzip(tsa.state.req), contentType: "text/html; charset=utf-8" }); } function sendAllTiddlers(tsa) { const { $tw, wikiInfo } = boot_startup_1.TiddlyWiki.loadWiki(tsa.folder); @@ -469,8 +471,8 @@ function sendAllTiddlers(tsa) { tsa.state.res.end(); } else { - sendResponse(tsa.state.res, text, { - doGzip: acceptGzip(tsa.state.req), + server_types_1.sendResponse(tsa.state.res, text, { + doGzip: server_types_1.canAcceptGzip(tsa.state.req), contentType: "application/json; charset=utf-8" }); } @@ -484,7 +486,7 @@ function handleTiddlersRoute(tsa) { if (tsa.state.req.method === "GET") { } return ((tsa.state.req.method === "PUT") - ? tsa.state.recieveBody().mapTo(tsa) + ? tsa.state.recieveBody(true).mapTo(tsa) : rx_1.Observable.of(tsa)).map(tsa => { }); } @@ -521,8 +523,8 @@ function getSkinnyTiddlers(tsa) { let body = Buffer.concat([ header, newLineBuffer, Buffer.from(encoding, 'binary'), newLineBuffer, text ]); - sendResponse(res, body, { - doGzip: acceptGzip(tsa.state.req), + server_types_1.sendResponse(res, body, { + doGzip: server_types_1.canAcceptGzip(tsa.state.req), contentType: "application/octet-stream" }); } @@ -535,33 +537,3 @@ function handleCacheRoute(tsa) { //folder during the mount sequence to generate it. //PUT DELETE } -function acceptGzip(header) { - if (((a) => typeof a === "object")(header)) { - header = header.headers['accept-encoding']; - } - var gzip = header.split(',').map(e => e.split(';')).filter(e => e[0] === "gzip")[0]; - return !!gzip && !!gzip[1] && parseFloat(gzip[1].split('=')[1]) > 0; -} -function sendResponse(res, body, options = {}) { - body = !Buffer.isBuffer(body) ? Buffer.from(body, 'utf8') : body; - if (options.doGzip) - zlib_1.gzip(body, (err, gzBody) => { - if (err) - _send(body, false); - else - _send(gzBody, true); - }); - else - _send(body, false); - function _send(body, isGzip) { - res.setHeader('Content-Length', Buffer.isBuffer(body) - ? body.length.toString() - : Buffer.byteLength(body, 'utf8').toString()); - if (isGzip) - res.setHeader('Content-Encoding', 'gzip'); - res.setHeader('Content-Type', options.contentType || 'text/plain; charset=utf-8'); - res.writeHead(200); - res.write(body); - res.end(); - } -} diff --git a/src/datafolder.ts b/src/datafolder.ts index a6232f6..4bb13c9 100644 --- a/src/datafolder.ts +++ b/src/datafolder.ts @@ -1,6 +1,6 @@ import { StateObject, keys, ServerConfig, AccessPathResult, AccessPathTag, DebugLogger, - PathResolverResult, obs_readFile, tryParseJSON, obs_readdir, JsonError, serveFolder, serveFolderIndex, + PathResolverResult, obs_readFile, tryParseJSON, obs_readdir, JsonError, serveFolder, serveFolderIndex, sendResponse, canAcceptGzip, Hashmap, ServerEventEmitter, } from "./server-types"; import { Observable, Subject } from "../lib/rx"; @@ -20,7 +20,10 @@ const debug = DebugLogger('DAT'); const loadedFolders: { [k: string]: FolderData | StateObject[] } = {}; const otherSocketPaths: { [k: string]: WebSocket[] } = {}; -export function init(eventer: EventEmitter) { +let eventer: ServerEventEmitter; + +export function init(e: ServerEventEmitter) { + eventer = e; eventer.on('settings', function (set: ServerConfig) { settings = set; }) @@ -174,6 +177,7 @@ function loadTiddlyWiki(mount: string, folder: string, reload: string) { var command = new serverCommand([], { wiki: $tw.wiki }); var server = command.server; + //If the username is changed the datafolder will just have to be reloaded server.set({ rootTiddler: "$:/core/save/all", renderType: "text/plain", @@ -379,7 +383,7 @@ function sendPluginResponse(state: StateObject, pluginCache: PluginCache | "null res.writeHead(304); res.end(); } else { - sendResponse(res, body, { doGzip: acceptGzip(req) }); + sendResponse(res, body, { doGzip: canAcceptGzip(req) }); } } @@ -486,7 +490,7 @@ function sendLoader(tsa: TSASO) { sendResponse( tsa.state.res, loaderText.replace(globalRegex, tsa.mount), - { doGzip: acceptGzip(tsa.state.req), contentType: "text/html; charset=utf-8" } + { doGzip: canAcceptGzip(tsa.state.req), contentType: "text/html; charset=utf-8" } ); } function sendAllTiddlers(tsa: TSASO) { @@ -517,7 +521,7 @@ function sendAllTiddlers(tsa: TSASO) { tsa.state.res.end(); } else { sendResponse(tsa.state.res, text, { - doGzip: acceptGzip(tsa.state.req), + doGzip: canAcceptGzip(tsa.state.req), contentType: "application/json; charset=utf-8" }); } @@ -537,7 +541,7 @@ function handleTiddlersRoute(tsa: TSASO) { return ((tsa.state.req.method === "PUT") - ? tsa.state.recieveBody().mapTo(tsa) + ? tsa.state.recieveBody(true).mapTo(tsa) : Observable.of(tsa) ).map(tsa => { @@ -583,7 +587,7 @@ function getSkinnyTiddlers(tsa) { header, newLineBuffer, Buffer.from(encoding, 'binary'), newLineBuffer, text ]); sendResponse(res, body, { - doGzip: acceptGzip(tsa.state.req), + doGzip: canAcceptGzip(tsa.state.req), contentType: "application/octet-stream" }); } @@ -596,32 +600,3 @@ function handleCacheRoute(tsa: TSASO) { //folder during the mount sequence to generate it. //PUT DELETE } -function acceptGzip(header: string | http.IncomingMessage) { - if (((a): a is http.IncomingMessage => typeof a === "object")(header)) { - header = header.headers['accept-encoding'] as string; - } - var gzip = header.split(',').map(e => e.split(';')).filter(e => e[0] === "gzip")[0]; - return !!gzip && !!gzip[1] && parseFloat(gzip[1].split('=')[1]) > 0 -} -function sendResponse(res: http.ServerResponse, body: Buffer | string, options: { - doGzip?: boolean, - contentType?: string -} = {}) { - body = !Buffer.isBuffer(body) ? Buffer.from(body, 'utf8') : body; - if (options.doGzip) gzip(body, (err, gzBody) => { - if (err) _send(body, false); - else _send(gzBody, true) - }); else _send(body, false); - - function _send(body, isGzip) { - res.setHeader('Content-Length', Buffer.isBuffer(body) - ? body.length.toString() - : Buffer.byteLength(body, 'utf8').toString()) - if (isGzip) res.setHeader('Content-Encoding', 'gzip'); - res.setHeader('Content-Type', options.contentType || 'text/plain; charset=utf-8'); - res.writeHead(200); - res.write(body); - res.end(); - } - -} \ No newline at end of file diff --git a/src/generateDirectoryListing.js b/src/generateDirectoryListing.js index fbe4471..f518dbb 100644 --- a/src/generateDirectoryListing.js +++ b/src/generateDirectoryListing.js @@ -38,6 +38,7 @@ exports.generateDirectoryListing = function (directory, options) { ${name} + diff --git a/src/generateSettingsPage.js b/src/generateSettingsPage.js index a8dd6fa..84104c9 100644 --- a/src/generateSettingsPage.js +++ b/src/generateSettingsPage.js @@ -3,41 +3,59 @@ Object.defineProperty(exports, "__esModule", { value: true }); const server_types_1 = require("./server-types"); const rx_1 = require("../lib/rx"); const path_1 = require("path"); +const fs_1 = require("fs"); let settings; let eventer; -let serveSettingsPage; +const debug = server_types_1.DebugLogger("APP SET"); +let serveSettingsRoot; +let serveSettingsTree; +function serveAssets() { + if (serveSettingsRoot) + serveSettingsRoot.complete(); + if (serveSettingsTree) + serveSettingsTree.complete(); + serveSettingsRoot = new rx_1.Subject(); + serveSettingsTree = new rx_1.Subject(); + server_types_1.serveFile(serveSettingsRoot.asObservable(), "settings-root.html", path_1.join(settings.__assetsDir, "settings-root")).subscribe(); + server_types_1.serveFile(serveSettingsTree.asObservable(), "settings-tree.html", path_1.join(settings.__assetsDir, "settings-tree")).subscribe(); +} function initSettingsRequest(e) { eventer = e; - eventer.on('settings', function (set) { + eventer.on('settings', (set) => { settings = set; - //serve the settings page file - if (serveSettingsPage) - serveSettingsPage.complete(); - serveSettingsPage = new rx_1.Subject(); - server_types_1.serveFile(serveSettingsPage.asObservable(), "settingsPage.html", settings.__assetsDir).subscribe(); + serveAssets(); + }); + eventer.on('settingsChanged', (keys) => { + if (keys.indexOf("__assetsDir") > -1) + serveAssets(); }); } exports.initSettingsRequest = initSettingsRequest; const data = [ - { type: 2, name: "tree", valueType: "subpage", valueOptions: { handler: (state) => { } } }, - { type: 0, name: "types", valueType: "function" }, - { type: 1, name: "host", valueType: "string" }, - { type: 1, name: "port", valueType: "number" }, - { type: 1, name: "username", valueType: "string" }, - { type: 1, name: "password", valueType: "string" }, - { type: 0, name: "backupDirectory", valueType: "string" }, - { type: 0, name: "etag", valueType: "enum", valueOptions: ["string", ["", "disabled", "required"]] }, - { type: 0, name: "etagWindow", valueType: "number" }, - { type: 1, name: "useTW5path", valueType: "boolean" }, - { type: 0, name: "debugLevel", valueType: "enum", valueOptions: ["number", [4, 3, 2, 1, 0, -1, -2, -3, -4]] }, + { level: 1, name: "tree", valueType: "subpage", handler: handleTreeSubpage }, + { level: 0, name: "types", valueType: "function", validate: validateTypes }, + { level: 1, name: "host", valueType: "string" }, + { level: 1, name: "port", valueType: "number" }, + { level: 1, name: "username", valueType: "string" }, + { level: 1, name: "password", valueType: "string" }, + { level: 0, name: "backupDirectory", valueType: "string" }, { - type: 1, + level: 0, name: "etag", valueType: "enum", + enumType: "string", enumOpts: ["", "disabled", "required"] + }, + { level: 0, name: "etagWindow", valueType: "number" }, + { level: 1, name: "useTW5path", valueType: "boolean" }, + { + level: 0, name: "debugLevel", valueType: "enum", + enumType: "number", + enumOpts: [4, 3, 2, 1, 0, -1, -2, -3, -4] + }, + { + level: 1, name: "allowNetwork", valueType: "hashmapenum", - valueOptions: [ - ["boolean"], - ["mkdir", "upload", "settings", "WARNING_all_settings_WARNING"] - ] + enumType: "boolean", + enumKeys: ["mkdir", "upload", "settings", "WARNING_all_settings_WARNING"], }, ]; const descriptions = { @@ -47,7 +65,9 @@ const descriptions = { + "127.0.0.1 only listens on localhost.
                    " + "TECHNICAL: 127.0.0.1 is always bound to even when another IP is specified.", port: "The port number to listen on.", - username: "The basic auth username to use. Also forwarded to data folders for signing edits.", + username: "The basic auth username to use (changes effective immediately). " + + "Also forwarded to data folders for signing edits. " + + "Active data folders will need to be reloaded for the new username to take effect.", password: "The basic auth password to use.", etag: "disabled (Don't check etags), " + "required (Require etags to be used), " @@ -61,174 +81,344 @@ const descriptions = { upload: "Allow network users to upload files.", settings: "Allow network users to change non-critical settings.", WARNING_all_settings_WARNING: "Allow network users to change critical settings: " - + `${data.filter(e => e.type > 0).map(e => e.name).join(', ')}` + + `${data.filter(e => e.level > 0).map(e => e.name).join(', ')}` }, maxAge: "", tsa: "", _disableLocalHost: "", __dirname: "READONLY: Directory of currently loaded settings file", + __filename: "READONLY: Full file path of the currently loaded settings file", __assetsDir: "" }; -function generateSettingsPage(key) { - // let out = ""; - // if (typeof key === "number") { - // out = data.map(item => - // processItem(item, settings[item.name], item.type > key, descriptions[item.name]) - // ).join('
                    \n'); - // } else { - // let item = data.find(e => e.name === key); - // if (!item) throw new Error("item was falsy"); - // out = processItem(item, settings[item.name], false, descriptions[item.name]) - // } - return ` - - - - - - - - - -`; +const primitives = ["string", "number", "boolean"]; +function isPrimitive(a) { + return primitives.indexOf(a.valueType) > -1; } -exports.generateSettingsPage = generateSettingsPage; -// type settings = keyof ServerConfig; -function processItem(item, defValue, readonly, description) { - const primitivesTypeMap = { - "string": "text", - "number": "number", - "boolean": "checkbox" - }; - const { valueType } = item; - const valueTypeParts = valueType.split('-'); - if (item.valueType === "function") { - // if (!item.valueOptions) return ""; - // else return `
                    ${item.name}${ - // item.valueOptions[0](defValue as any, [item.name], readonly, description) - // }
                    `; - } - else if (item.valueType === "hashmapenum") { - if (!item.valueOptions) - return ""; - const dataTypes = item.valueOptions[0]; - const valueOptions = item.valueOptions[1]; - return `
                    ${item.name}${valueOptions.map((e, i) => `${processItem({ name: e, type: item.type, valueType: dataTypes[0] }, defValue[e], readonly, description[e])}`).join('\n')}
                    `; - } - else if (Object.keys(primitivesTypeMap).indexOf(item.valueType) > -1) { - let type = primitivesTypeMap[item.valueType]; - return `
                    ${item.name} ${description}
                    `; +function testPrimitive(valueType, value) { + if (typeof value === valueType) + return { valid: true, value }; + else if (valueType === "boolean") { + switch (value) { + case 1: + case "yes": + case "true": + value = true; + break; + case 0: + case "no": + case "false": + value = false; + break; + } + return { valid: typeof value === "boolean", value }; } - else if (item.valueType === "enum") { - if (!item.valueOptions) - return ""; - let options = item.valueOptions[1]; - let type = item.valueOptions[0]; - return ` -
                    ${item.name} - ${description} -
                    `; + else if (valueType === "number") { + let test; + test = +value; + return { valid: test === test, value: test }; } -} -function treeGenerate(defValue, keys) { - let res = ""; - let type = (val) => `onclick="this.form.elements.tree.disabled=true;" ${(typeof defValue === val ? "checked" : "")}`; - res += `
                    Root Mount Type
                    `; - if (typeof defValue === "object") { - // res = `
                    ${keys.length > 1 ? `
                    ${keys[keys.length - 1]}
                    ` : ""}\n` - // + Object.keys(defValue).map(e => `
                    ${treeFunction(defValue[e], keys.concat(e))}
                    `).join('\n') - // + `
                    ` - res += `

                    Add or remove folders in the directory index

                    `; - res += ``; + return { valid: false, value }; } - return res; } -function treeValidate(post) { - let checks = [post.treeType === "string" || post.treeType === "object"]; - let getChecks = () => checks.filter(e => { - Array.isArray(e) ? !e[0] : !e; - }); - return rx_1.Observable.of({}).mergeMap(() => { - if (post.treeType === "string") { - checks.push([typeof post.tree === "string", "TREETYPE_CHANGED"]); - let treePath = path_1.resolve(settings.__dirname, post.tree); - return server_types_1.obs_stat()(treePath); +function updateSettings(level, upd, current) { + let allowdata = data.filter(e => +e.level <= level); + const valids = allowdata.map(item => { + if (item.level > level) + return { valid: false, changed: false }; + let key = item.name; + let changed = false; + if (isPrimitive(item)) { + let { valid, value } = testPrimitive(item.valueType, upd[key]); + if (valid && (value !== current[key])) { + current[key] = value; + changed = true; + } + return { valid, changed }; + } + else if (item.valueType === "function") { + let { valid, value, changed } = item.validate(level, JSON.parse(JSON.stringify(upd)), current); + //depend on the function to tell us whether the setting changed + if (valid && changed) { + current[key] = value; + changed = true; + } + return { valid, changed }; + } + else if (item.valueType === "subpage") { + //subpage handlers take care of validation and saving. + //if it's here, it shouldn't be. + return { valid: false, changed: false }; + } + else if (item.valueType === "hashmapenum") { + if (typeof current[key] !== "object") + current[key] = {}; + return item.enumKeys.map(e => { + let { valid, value } = testPrimitive(item.enumType, upd[key]); + if (valid && (value !== current[key][e])) { + current[key][e] = value; + changed = true; + } + return { valid, changed }; + }).reduce((n, e) => { + n.valid = e.valid && n.valid; + n.changed = e.changed || n.changed; + return n; + }, { valid: true, changed: false }); + } + else if (item.valueType === "enum") { + let { valid, value } = testPrimitive(item.enumType, upd[key]); + if (valid && (current[key] !== value) && item.enumOpts.indexOf(value) > -1) { + current[key] = value; + changed = true; + return { valid, changed }; + } + else + return { valid, changed }; } else { - return rx_1.Observable.of("true"); + return { valid: false, changed: false }; } - }).map((res) => { - if (!Array.isArray(res)) - return getChecks(); - let [err, stat, tag, filePath] = res; - checks.push([!err, "The specified path does not exist"]); - checks.push([ - stat.isDirectory() || stat.isFile(), - "The specified path is not a directory or file." - ]); - return getChecks(); }); + let keys = []; + let response = allowdata.map((item, i) => { + let { valid, changed } = valids[i]; + if (changed) + keys.push(item.name); + return { key: item.name, valid, changed }; + }); + return { response, keys }; } -function treeSave(post, checks) { - //OK, this whole tree thing is vulnerable to a critical attack - //I drive myself crazy thinking of every single scenario. - //Evil Villian: OK, let me make an Iframe that will load the - // localhost page and then I will add a tree item - // pointing to the C:/ drive and download the - // registry and passwords. - //https://security.stackexchange.com/a/29502/109521 - let ch = checks.filter(e => Array.isArray(e) && e[1] === "TREETYPE_CHANGED"); - let tt = typeof settings.tree === post.treeType; - if (ch.length && checks.length === 1) { - settings.tree = post.tree; - eventer.emit("settings", settings); - } -} -function typesFunction(defValue, keys) { - // return `
                    ${keys.length > 1 ? `
                    ${keys[keys.length - 1]}
                    ` : ""}\n` - // + Object.keys(defValue).map(e => `
                    ${e}
                    ${defValue[e].map(f => `
                    ${f}
                    `).join('')}
                    `).join('\n') - // + `
                    ` - return `
                    ${Object.keys(defValue).map(e => `
                    ${e}
                    `)}
                    `; +function validateTypes(level, upd, current) { + return { valid: true, value: [], changed: false }; } function handleSettingsRequest(state) { - if (state.req.method === "GET") { - console.log(state.path); - // let key; - // if (state.path.length > 3) { - // let l2index = data.filter(e => e.type === 2).map(e => e.name).indexOf(state.path[3]); - // if (l2index > -1) key = data[l2index].name - // else return state.throw(404); - // } else { - // key = (state.isLocalHost || settings.allowNetwork.WARNING_all_settings_WARNING) ? 1 - // : (settings.allowNetwork.settings ? 0 : -1); - // } - // let data; - if (state.path[3] === "") { - // console.log("serving"); - serveSettingsPage.next(state); - // state.res.writeHead(200); - // state.res.write(JSON.stringify({ data, settings, descriptions }, null, 2)); - // state.res.end(); + let level = (state.isLocalHost || settings.allowNetwork.WARNING_all_settings_WARNING) ? 1 + : (settings.allowNetwork.settings ? 0 : -1); + if (state.path[3] === "") { + if (state.req.method === "GET") { + if (state.url.query.action === "getdata" && state.req.method === "GET") { + fs_1.readFile(settings.__filename, "utf8", (err, setfile) => { + let curjson = server_types_1.tryParseJSON(setfile, (err) => { + state.throw(500, "Settings file could not be accessed"); + }); + if (typeof curjson !== "undefined") { + let set = {}; + data.forEach(item => { + // if(item.level > level) return; + set[item.name] = settings[item.name]; + }); + server_types_1.sendResponse(state.res, JSON.stringify({ level, data, descriptions, settings: set }), { + contentType: "application/json", + doGzip: server_types_1.canAcceptGzip(state.req) + }); + } + }); + } + else { + serveSettingsRoot.next(state); + } + } + else if (state.req.method === "PUT") { + if (state.url.query.action === "update") { + handleSettingsUpdate(state, level); + } + else + state.throw(404); } + else + state.throw(405); + } + else if (typeof state.path[3] === "string") { + let key; + let subpages = data.filter((e) => e.valueType === "subpage"); + let subIndex = subpages.map(e => e.name).indexOf(state.path[3]); + if (subIndex === -1) + return state.throw(404); + let subpage = subpages[subIndex]; + if (subpage.level > level) + return state.throw(403); + subpage.handler(state); } } exports.handleSettingsRequest = handleSettingsRequest; +function handleSettingsUpdate(state, level) { + state.recieveBody(true).concatMap(() => { + if (typeof state.json === "undefined") + return rx_1.Observable.empty(); + debug(1, "Settings PUT %s", JSON.stringify(state.json)); + return server_types_1.obs_readFile()(settings.__filename, "utf8"); + }).concatMap(r => { + let [err, res] = r; + let threw = false, curjson = server_types_1.tryParseJSON(res, (err) => { + state.throw(500, "Settings file could not be accessed"); + threw = true; + }); + if (threw) + return rx_1.Observable.empty(); + let { response, keys } = updateSettings(level, state.json, curjson); + const tag = { curjson, keys, response }; + if (keys.length) { + let newfile = JSON.stringify(curjson, null, 2); + return server_types_1.obs_writeFile(tag)(settings.__filename, newfile); + } + else { + return rx_1.Observable.of([undefined, tag]); + // return Observable.empty(); + } + }).subscribe(r => { + const [error, { curjson, keys, response }] = r; + // (error?: NodeJS.ErrnoException) => { + if (error) { + state.log(2, "Error writing settings file: %s %s\n%s", error.code, error.message, error.path).throw(500); + } + else { + if (keys.length) { + debug(1, "New settings written to current settings file"); + server_types_1.normalizeSettings(curjson, settings.__filename); + keys.forEach(k => { + settings[k] = curjson[k]; + }); + eventer.emit('settingsChanged', keys); + } + server_types_1.sendResponse(state.res, JSON.stringify(response), { + contentType: "application/json", + doGzip: server_types_1.canAcceptGzip(state.req) + }); + } + // } + }); +} +function handleTreeSubpage(state) { + let level = (state.isLocalHost || settings.allowNetwork.WARNING_all_settings_WARNING) ? 1 + : (settings.allowNetwork.settings ? 0 : -1); + // we don't need to process anything here because the user will paste the new settings into + // settings.json and then restart the server. The best way to prevent unauthorized access + // is to not build a door. If code running on the user's computer can't access the file system + // then we shouldn't give it access through a server running on localhost by allowing it to add + // tree items. Oh well, not much we can do about it knowing the current paths. + if (state.req.method !== "GET") + return state.throw(405); + if (state.url.search === "") { + serveSettingsTree.next(state); + } + else if (state.url.query.action === "getdata") { + server_types_1.sendResponse(state.res, JSON.stringify({ level, settings }), { + contentType: "application/json", + doGzip: server_types_1.canAcceptGzip(state.req) + }); + } +} +// function processItem(item: SettingsPageItemTypes, defValue: any, readonly: boolean, description: any) { +// const primitivesTypeMap = { +// "string": "text", +// "number": "number", +// "boolean": "checkbox" +// } +// const { valueType } = item; +// const valueTypeParts = valueType.split('-'); +// if (item.valueType === "function") { +// // if (!item.valueOptions) return ""; +// // else return `
                    ${item.name}${ +// // item.valueOptions[0](defValue as any, [item.name], readonly, description) +// // }
                    `; +// } else if (item.valueType === "hashmapenum") { +// if (!item.valueOptions) return ""; +// const dataTypes = item.valueOptions[0]; +// const valueOptions = item.valueOptions[1]; +// return `
                    ${item.name}${valueOptions.map((e, i) => `${ +// processItem({ name: e, type: item.type, valueType: dataTypes[0] }, defValue[e], readonly, description[e]) +// }`).join('\n')}
                    `; +// } else if (Object.keys(primitivesTypeMap).indexOf(item.valueType) > -1) { +// let type = primitivesTypeMap[item.valueType]; +// return `
                    ${item.name} ${description}
                    `; +// } else if (item.valueType === "enum") { +// if (!item.valueOptions) return ""; +// let options = item.valueOptions[1]; +// let type = item.valueOptions[0]; +// return ` +//
                    ${item.name} +// ${description} +//
                    `; +// } +// } +// function treeGenerate(defValue: any, keys: string[]) { +// let res = ""; +// let type = (val) => +// `onclick="this.form.elements.tree.disabled=true;" ${(typeof defValue === val ? "checked" : "")}`; +// res += `
                    Root Mount Type
                    `; +// if (typeof defValue === "object") { +// // res = `
                    ${keys.length > 1 ? `
                    ${keys[keys.length - 1]}
                    ` : ""}\n` +// // + Object.keys(defValue).map(e => `
                    ${treeFunction(defValue[e], keys.concat(e))}
                    `).join('\n') +// // + `
                    ` +// res += `

                    Add or remove folders in the directory index

                    `; +// res += `` +// } +// return res; +// } +// type TreeCheck = (boolean | [boolean, string]) +// function treeValidate(post: Hashmap) { +// let checks: TreeCheck[] = [post.treeType === "string" || post.treeType === "object"]; +// let getChecks = () => checks.filter(e => { +// Array.isArray(e) ? !e[0] : !e; +// }); +// return Observable.of({}).mergeMap(() => { +// if (post.treeType === "string") { +// checks.push([typeof post.tree === "string", "TREETYPE_CHANGED"]); +// let treePath = resolve(settings.__dirname, post.tree); +// return obs_stat()(treePath); +// } else { +// return Observable.of("true"); +// } +// }).map((res) => { +// if (!Array.isArray(res)) return getChecks(); +// let [err, stat, tag, filePath] = res; +// checks.push([!err, "The specified path does not exist"]); +// checks.push([ +// stat.isDirectory() || stat.isFile(), +// "The specified path is not a directory or file." +// ]); +// return getChecks(); +// }) +// } +// function treeSave(post: Hashmap, checks: TreeCheck[]) { +// //OK, this whole tree thing is vulnerable to a critical attack +// //I drive myself crazy thinking of every single scenario. +// //Evil Villian: OK, let me make an Iframe that will load the +// // localhost page and then I will add a tree item +// // pointing to the C:/ drive and download the +// // registry and passwords. +// //https://security.stackexchange.com/a/29502/109521 +// let ch = checks.filter(e => Array.isArray(e) && e[1] === "TREETYPE_CHANGED"); +// let tt = typeof settings.tree === post.treeType; +// if (ch.length && checks.length === 1) { +// settings.tree = post.tree; +// eventer.emit("settings", settings); +// } +// } +// function typesFunction(defValue: any, keys: string[]) { +// // return `
                    ${keys.length > 1 ? `
                    ${keys[keys.length - 1]}
                    ` : ""}\n` +// // + Object.keys(defValue).map(e => `
                    ${e}
                    ${defValue[e].map(f => `
                    ${f}
                    `).join('')}
                    `).join('\n') +// // + `
                    ` +// return `
                    ${Object.keys(defValue).map(e => +// `
                    ${e}
                    ` +// )}
                    `; +// } diff --git a/src/generateSettingsPage.ts b/src/generateSettingsPage.ts index 706fe71..ec4ee0c 100644 --- a/src/generateSettingsPage.ts +++ b/src/generateSettingsPage.ts @@ -1,74 +1,92 @@ -import { ServerConfig, StateObject, Hashmap, obs_stat, serveFile } from "./server-types"; +import { ServerConfig, StateObject, Hashmap, obs_stat, serveFile, sendResponse, canAcceptGzip, recieveBody, DebugLogger, ServerEventEmitter, tryParseJSON, normalizeSettings, obs_readFile, obs_writeFile } from "./server-types"; import { EventEmitter } from "events"; import { Observable, Subject } from "../lib/rx"; -import { resolve } from "path"; +import { resolve, join } from "path"; +import { readFileSync, readFile, writeFile } from "fs"; let settings: ServerConfig; -let eventer: EventEmitter; -let serveSettingsPage: Subject; +let eventer: ServerEventEmitter; + +const debug = DebugLogger("APP SET"); + +let serveSettingsRoot: Subject; +let serveSettingsTree: Subject; + +function serveAssets() { + if (serveSettingsRoot) serveSettingsRoot.complete(); + if (serveSettingsTree) serveSettingsTree.complete(); + + serveSettingsRoot = new Subject(); + serveSettingsTree = new Subject(); + + serveFile(serveSettingsRoot.asObservable(), "settings-root.html", join(settings.__assetsDir, "settings-root")).subscribe(); + serveFile(serveSettingsTree.asObservable(), "settings-tree.html", join(settings.__assetsDir, "settings-tree")).subscribe(); + +} export function initSettingsRequest(e) { eventer = e; - eventer.on('settings', function (set: ServerConfig) { + eventer.on('settings', (set) => { settings = set; - //serve the settings page file - if (serveSettingsPage) serveSettingsPage.complete(); - serveSettingsPage = new Subject(); - serveFile(serveSettingsPage.asObservable(), "settingsPage.html", settings.__assetsDir).subscribe(); + serveAssets(); }); + eventer.on('settingsChanged', (keys) => { + if (keys.indexOf("__assetsDir") > -1) serveAssets(); + }) } - +type primitive = "string" | "number" | "boolean"; type SettingsPageItem = { - type: 0 | 1 | 2, - name: string, - // valueType: string, - // valueOptions?: any[] + level: 0 | 1 | 2, + name: keyof ServerConfig, }; type ValueType_function = { valueType: "function", - // valueOptions: [(defValue: any, keys: string[], readOnly: boolean, description: any) => string] + validate: (level: number, upd: ServerConfig, current: ServerConfig) => { valid: boolean, value: any, changed: boolean } } & SettingsPageItem; type ValueType_primitive = { - valueType: "string" | "number" | "boolean" + valueType: primitive } & SettingsPageItem; type ValueType_enum = { valueType: "enum", - valueOptions: ["number" | "string", (number | string)[]] + enumType: primitive, + enumOpts: any[] + // valueOptions: ["number" | "string", (number | string)[]] } & SettingsPageItem; type ValueType_hashmapenum = { valueType: "hashmapenum", - valueOptions: [("string" | "number" | "boolean")[], string[]] + enumType: primitive, + enumKeys: string[] } & SettingsPageItem; type ValueType_subpage = { valueType: "subpage", - valueOptions: { - handler: (state: StateObject) => void; - } + handler: (state: StateObject) => void; } & SettingsPageItem; type SettingsPageItemTypes = ValueType_function | ValueType_enum | ValueType_hashmapenum | ValueType_primitive | ValueType_subpage; const data: (SettingsPageItemTypes)[] = [ - { type: 2, name: "tree", valueType: "subpage", valueOptions: { handler: (state) => { } } }, - { type: 0, name: "types", valueType: "function" }, - { type: 1, name: "host", valueType: "string" }, - { type: 1, name: "port", valueType: "number" }, - { type: 1, name: "username", valueType: "string" }, - { type: 1, name: "password", valueType: "string" }, - { type: 0, name: "backupDirectory", valueType: "string" }, - { type: 0, name: "etag", valueType: "enum", valueOptions: ["string", ["", "disabled", "required"]] }, - { type: 0, name: "etagWindow", valueType: "number" }, - { type: 1, name: "useTW5path", valueType: "boolean" }, - { type: 0, name: "debugLevel", valueType: "enum", valueOptions: ["number", [4, 3, 2, 1, 0, -1, -2, -3, -4]] }, + { level: 1, name: "tree", valueType: "subpage", handler: handleTreeSubpage }, + { level: 0, name: "types", valueType: "function", validate: validateTypes }, + { level: 1, name: "host", valueType: "string" }, + { level: 1, name: "port", valueType: "number" }, + { level: 1, name: "username", valueType: "string" }, + { level: 1, name: "password", valueType: "string" }, + { level: 0, name: "backupDirectory", valueType: "string" }, + { + level: 0, name: "etag", valueType: "enum", + enumType: "string", enumOpts: ["", "disabled", "required"] + }, + { level: 0, name: "etagWindow", valueType: "number" }, + { level: 1, name: "useTW5path", valueType: "boolean" }, { - type: 1, + level: 0, name: "debugLevel", valueType: "enum", + enumType: "number", + enumOpts: [4, 3, 2, 1, 0, -1, -2, -3, -4] + }, + { + level: 1, name: "allowNetwork", valueType: "hashmapenum", - valueOptions: [ - ["boolean"], - ["mkdir", "upload", "settings", "WARNING_all_settings_WARNING"] - ] as [("string" | "number" | "boolean")[], (keyof ServerConfig["allowNetwork"])[]] + enumType: "boolean", + enumKeys: ["mkdir", "upload", "settings", "WARNING_all_settings_WARNING"], }, - // { type: "disabled", name: "_disableLocalHost" }, - // { type: "disabled", name: "tsa" }, - // { type: "disabled", name: "maxAge" } ]; const descriptions: {[K in keyof ServerConfig]: any} = { @@ -78,7 +96,9 @@ const descriptions: {[K in keyof ServerConfig]: any} = { + "127.0.0.1 only listens on localhost.
                    " + "TECHNICAL: 127.0.0.1 is always bound to even when another IP is specified.", port: "The port number to listen on.", - username: "The basic auth username to use. Also forwarded to data folders for signing edits.", + username: "The basic auth username to use (changes effective immediately). " + + "Also forwarded to data folders for signing edits. " + + "Active data folders will need to be reloaded for the new username to take effect.", password: "The basic auth password to use.", etag: "disabled (Don't check etags), " + "required (Require etags to be used), " @@ -92,183 +112,325 @@ const descriptions: {[K in keyof ServerConfig]: any} = { upload: "Allow network users to upload files.", settings: "Allow network users to change non-critical settings.", WARNING_all_settings_WARNING: "Allow network users to change critical settings: " - + `${data.filter(e => e.type > 0).map(e => e.name).join(', ')}` + + `${data.filter(e => e.level > 0).map(e => e.name).join(', ')}` }, maxAge: "", tsa: "", _disableLocalHost: "", __dirname: "READONLY: Directory of currently loaded settings file", + __filename: "READONLY: Full file path of the currently loaded settings file", __assetsDir: "" } -export function generateSettingsPage(key: number | string) { - // let out = ""; - // if (typeof key === "number") { - // out = data.map(item => - // processItem(item, settings[item.name], item.type > key, descriptions[item.name]) - // ).join('
                    \n'); - // } else { - // let item = data.find(e => e.name === key); - // if (!item) throw new Error("item was falsy"); - // out = processItem(item, settings[item.name], false, descriptions[item.name]) - // } - - return ` - - - - - - - - -`; +const primitives = ["string", "number", "boolean"]; +function isPrimitive(a): a is ValueType_primitive { + return primitives.indexOf(a.valueType) > -1; } - -// type settings = keyof ServerConfig; - -function processItem(item: SettingsPageItem & ValueType, defValue: any, readonly: boolean, description: any) { - const primitivesTypeMap = { - "string": "text", - "number": "number", - "boolean": "checkbox" +function testPrimitive(valueType: "string" | "number" | "boolean", value: any): { valid: boolean, value: any } { + if (typeof value === valueType) + return { valid: true, value }; + else if (valueType === "boolean") { + switch (value) { + case 1: + case "yes": + case "true": value = true; break; + case 0: + case "no": + case "false": value = false; break; + } + return { valid: typeof value === "boolean", value } + } else if (valueType === "number") { + let test: any; + test = +value; + return { valid: test === test, value: test } + } else if (valueType === "string") { + try { + return { valid: true, value: value.toString() } + } catch (e) { + return { valid: false, value }; + } + } else { + return { valid: false, value } } - const { valueType } = item; - const valueTypeParts = valueType.split('-'); +} - if (item.valueType === "function") { - // if (!item.valueOptions) return ""; - // else return `
                    ${item.name}${ - // item.valueOptions[0](defValue as any, [item.name], readonly, description) - // }
                    `; - } else if (item.valueType === "hashmapenum") { - if (!item.valueOptions) return ""; - const dataTypes = item.valueOptions[0]; - const valueOptions = item.valueOptions[1]; - return `
                    ${item.name}${valueOptions.map((e, i) => `${ - processItem({ name: e, type: item.type, valueType: dataTypes[0] }, defValue[e], readonly, description[e]) - }`).join('\n')}
                    `; - } else if (Object.keys(primitivesTypeMap).indexOf(item.valueType) > -1) { - let type = primitivesTypeMap[item.valueType]; +function updateSettings(level: number, upd: ServerConfig, current: ServerConfig) { + let allowdata = data.filter(e => +e.level <= level); + const valids = allowdata.map(item => { + if (item.level > level) return { valid: false, changed: false }; + let key = item.name; + let changed = false; + if (isPrimitive(item)) { + let { valid, value } = testPrimitive(item.valueType, upd[key]); + if (valid && (value !== current[key])) { current[key] = value; changed = true; } + return { valid, changed }; + } else if (item.valueType === "function") { + let { valid, value, changed } = item.validate(level, JSON.parse(JSON.stringify(upd)), current); + //depend on the function to tell us whether the setting changed + if (valid && changed) { current[key] = value; changed = true; } + return { valid, changed }; + } else if (item.valueType === "subpage") { + //subpage handlers take care of validation and saving. + //if it's here, it shouldn't be. + return { valid: false, changed: false }; + } else if (item.valueType === "hashmapenum") { + if (typeof current[key] !== "object") current[key] = {}; + return item.enumKeys.map(e => { + let { valid, value } = testPrimitive(item.enumType, upd[key]); + if (valid && (value !== current[key][e])) { + current[key][e] = value; + changed = true; + } + return { valid, changed }; + }).reduce((n, e) => { + n.valid = e.valid && n.valid; + n.changed = e.changed || n.changed; + return n; + }, { valid: true, changed: false }); + } else if (item.valueType === "enum") { + let { valid, value } = testPrimitive(item.enumType, upd[key]); + if (valid && (current[key] !== value) && item.enumOpts.indexOf(value) > -1) { + current[key] = value; + changed = true; + return { valid, changed }; + } else + return { valid, changed }; + } else { + return { valid: false, changed: false }; + } + }); - return `
                    ${item.name} ${description}
                    `; + let keys: (keyof ServerConfig)[] = []; + let response = allowdata.map((item, i) => { + let { valid, changed } = valids[i]; + if (changed) keys.push(item.name); + return { key: item.name, valid, changed }; + }) + return { response, keys }; +} +function validateTypes(level: number, upd: ServerConfig, current: ServerConfig) { + return { valid: true, value: [], changed: false }; +} +export function handleSettingsRequest(state: StateObject) { + let level = (state.isLocalHost || settings.allowNetwork.WARNING_all_settings_WARNING) ? 1 + : (settings.allowNetwork.settings ? 0 : -1); - } else if (item.valueType === "enum") { - if (!item.valueOptions) return ""; - let options = item.valueOptions[1]; - let type = item.valueOptions[0]; + if (state.path[3] === "") { + if (state.req.method === "GET") { + if (state.url.query.action === "getdata" && state.req.method === "GET") { - return ` -
                    ${item.name} - ${description} -
                    `; + readFile(settings.__filename, "utf8", (err, setfile) => { + let curjson = tryParseJSON(setfile, (err) => { + state.throw(500, "Settings file could not be accessed"); + }) + if (typeof curjson !== "undefined") { + let set = {}; + data.forEach(item => { + // if(item.level > level) return; + set[item.name] = settings[item.name]; + }) + sendResponse(state.res, JSON.stringify({ level, data, descriptions, settings: set }), { + contentType: "application/json", + doGzip: canAcceptGzip(state.req) + }) + } + }); + } else { + serveSettingsRoot.next(state); + } + } else if (state.req.method === "PUT") { + if (state.url.query.action === "update") { + handleSettingsUpdate(state, level); + } else state.throw(404); + } else state.throw(405); + } else if (typeof state.path[3] === "string") { + let key: string; + let subpages = data.filter((e): e is ValueType_subpage => e.valueType === "subpage"); + let subIndex = subpages.map(e => e.name).indexOf(state.path[3] as any) + if (subIndex === -1) + return state.throw(404); + let subpage = subpages[subIndex]; + if (subpage.level > level) + return state.throw(403); + subpage.handler(state); } -} -function treeGenerate(defValue: any, keys: string[]) { - let res = ""; - let type = (val) => - `onclick="this.form.elements.tree.disabled=true;" ${(typeof defValue === val ? "checked" : "")}`; - res += `
                    Root Mount Type
                    `; - if (typeof defValue === "object") { - // res = `
                    ${keys.length > 1 ? `
                    ${keys[keys.length - 1]}
                    ` : ""}\n` - // + Object.keys(defValue).map(e => `
                    ${treeFunction(defValue[e], keys.concat(e))}
                    `).join('\n') - // + `
                    ` - res += `

                    Add or remove folders in the directory index

                    `; - res += `` - } - return res; } -type TreeCheck = (boolean | [boolean, string]) -function treeValidate(post: Hashmap) { - let checks: TreeCheck[] = [post.treeType === "string" || post.treeType === "object"]; - let getChecks = () => checks.filter(e => { - Array.isArray(e) ? !e[0] : !e; - }); - return Observable.of({}).mergeMap(() => { - if (post.treeType === "string") { - checks.push([typeof post.tree === "string", "TREETYPE_CHANGED"]); - let treePath = resolve(settings.__dirname, post.tree); - return obs_stat()(treePath); +function handleSettingsUpdate(state: StateObject, level: number) { + state.recieveBody(true).concatMap(() => { + if (typeof state.json === "undefined") return Observable.empty(); + debug(1, "Settings PUT %s", JSON.stringify(state.json)); + return obs_readFile()(settings.__filename, "utf8"); + }).concatMap(r => { + let [err, res] = r + let threw = false, curjson = tryParseJSON(res, (err) => { + state.throw(500, "Settings file could not be accessed"); + threw = true + }); + if (threw) return Observable.empty(); + let { response, keys } = updateSettings(level, state.json, curjson); + const tag = { curjson, keys, response }; + if (keys.length) { + let newfile = JSON.stringify(curjson, null, 2); + return obs_writeFile(tag)(settings.__filename, newfile); } else { - return Observable.of("true"); + return Observable.of([undefined, tag] as [undefined, typeof tag]); + // return Observable.empty(); + } + }).subscribe(r => { + const [error, { curjson, keys, response }] = r; + // (error?: NodeJS.ErrnoException) => { + if (error) { + state.log(2, "Error writing settings file: %s %s\n%s", + error.code, error.message, error.path).throw(500); + } else { + if (keys.length) { + debug(1, "New settings written to current settings file"); + normalizeSettings(curjson, settings.__filename); + keys.forEach(k => { + settings[k] = curjson[k]; + }); + eventer.emit('settingsChanged', keys as any); + } + + sendResponse(state.res, JSON.stringify(response), { + contentType: "application/json", + doGzip: canAcceptGzip(state.req) + }); } - }).map((res) => { - if (!Array.isArray(res)) return getChecks(); - let [err, stat, tag, filePath] = res; - checks.push([!err, "The specified path does not exist"]); - checks.push([ - stat.isDirectory() || stat.isFile(), - "The specified path is not a directory or file." - ]); - return getChecks(); + // } }) } -function treeSave(post: Hashmap, checks: TreeCheck[]) { - //OK, this whole tree thing is vulnerable to a critical attack - //I drive myself crazy thinking of every single scenario. - //Evil Villian: OK, let me make an Iframe that will load the - // localhost page and then I will add a tree item - // pointing to the C:/ drive and download the - // registry and passwords. - //https://security.stackexchange.com/a/29502/109521 - let ch = checks.filter(e => Array.isArray(e) && e[1] === "TREETYPE_CHANGED"); - let tt = typeof settings.tree === post.treeType; - if (ch.length && checks.length === 1) { - settings.tree = post.tree; - eventer.emit("settings", settings); +function handleTreeSubpage(state: StateObject) { + let level = (state.isLocalHost || settings.allowNetwork.WARNING_all_settings_WARNING) ? 1 + : (settings.allowNetwork.settings ? 0 : -1); + // we don't need to process anything here because the user will paste the new settings into + // settings.json and then restart the server. The best way to prevent unauthorized access + // is to not build a door. If code running on the user's computer can't access the file system + // then we shouldn't give it access through a server running on localhost by allowing it to add + // tree items. Oh well, not much we can do about it knowing the current paths. + if (state.req.method !== "GET") + return state.throw(405); + if (state.url.search === "") { + serveSettingsTree.next(state); + } else if (state.url.query.action === "getdata") { + sendResponse(state.res, JSON.stringify({ level, settings }), { + contentType: "application/json", + doGzip: canAcceptGzip(state.req) + }) } } -function typesFunction(defValue: any, keys: string[]) { - // return `
                    ${keys.length > 1 ? `
                    ${keys[keys.length - 1]}
                    ` : ""}\n` - // + Object.keys(defValue).map(e => `
                    ${e}
                    ${defValue[e].map(f => `
                    ${f}
                    `).join('')}
                    `).join('\n') - // + `
                    ` - return `
                    ${Object.keys(defValue).map(e => - `
                    ${e}
                    ` - )}
                    `; -} -export function handleSettingsRequest(state: StateObject) { - if (state.req.method === "GET") { - console.log(state.path); - // let key; - // if (state.path.length > 3) { - // let l2index = data.filter(e => e.type === 2).map(e => e.name).indexOf(state.path[3]); - // if (l2index > -1) key = data[l2index].name - // else return state.throw(404); - // } else { - // key = (state.isLocalHost || settings.allowNetwork.WARNING_all_settings_WARNING) ? 1 - // : (settings.allowNetwork.settings ? 0 : -1); - // } - // let data; - if (state.path[3] === "") { - // console.log("serving"); - serveSettingsPage.next(state); - // state.res.writeHead(200); - // state.res.write(JSON.stringify({ data, settings, descriptions }, null, 2)); - // state.res.end(); - } - } -} \ No newline at end of file + +// function processItem(item: SettingsPageItemTypes, defValue: any, readonly: boolean, description: any) { +// const primitivesTypeMap = { +// "string": "text", +// "number": "number", +// "boolean": "checkbox" +// } +// const { valueType } = item; +// const valueTypeParts = valueType.split('-'); + +// if (item.valueType === "function") { +// // if (!item.valueOptions) return ""; +// // else return `
                    ${item.name}${ +// // item.valueOptions[0](defValue as any, [item.name], readonly, description) +// // }
                    `; +// } else if (item.valueType === "hashmapenum") { +// if (!item.valueOptions) return ""; +// const dataTypes = item.valueOptions[0]; +// const valueOptions = item.valueOptions[1]; +// return `
                    ${item.name}${valueOptions.map((e, i) => `${ +// processItem({ name: e, type: item.type, valueType: dataTypes[0] }, defValue[e], readonly, description[e]) +// }`).join('\n')}
                    `; +// } else if (Object.keys(primitivesTypeMap).indexOf(item.valueType) > -1) { +// let type = primitivesTypeMap[item.valueType]; + +// return `
                    ${item.name} ${description}
                    `; + +// } else if (item.valueType === "enum") { +// if (!item.valueOptions) return ""; +// let options = item.valueOptions[1]; +// let type = item.valueOptions[0]; + +// return ` +//
                    ${item.name} +// ${description} +//
                    `; +// } +// } +// function treeGenerate(defValue: any, keys: string[]) { +// let res = ""; +// let type = (val) => +// `onclick="this.form.elements.tree.disabled=true;" ${(typeof defValue === val ? "checked" : "")}`; + +// res += `
                    Root Mount Type
                    `; +// if (typeof defValue === "object") { +// // res = `
                    ${keys.length > 1 ? `
                    ${keys[keys.length - 1]}
                    ` : ""}\n` +// // + Object.keys(defValue).map(e => `
                    ${treeFunction(defValue[e], keys.concat(e))}
                    `).join('\n') +// // + `
                    ` +// res += `

                    Add or remove folders in the directory index

                    `; +// res += `` +// } +// return res; +// } +// type TreeCheck = (boolean | [boolean, string]) +// function treeValidate(post: Hashmap) { +// let checks: TreeCheck[] = [post.treeType === "string" || post.treeType === "object"]; +// let getChecks = () => checks.filter(e => { +// Array.isArray(e) ? !e[0] : !e; +// }); +// return Observable.of({}).mergeMap(() => { +// if (post.treeType === "string") { +// checks.push([typeof post.tree === "string", "TREETYPE_CHANGED"]); +// let treePath = resolve(settings.__dirname, post.tree); +// return obs_stat()(treePath); +// } else { +// return Observable.of("true"); +// } +// }).map((res) => { +// if (!Array.isArray(res)) return getChecks(); +// let [err, stat, tag, filePath] = res; +// checks.push([!err, "The specified path does not exist"]); +// checks.push([ +// stat.isDirectory() || stat.isFile(), +// "The specified path is not a directory or file." +// ]); +// return getChecks(); +// }) +// } +// function treeSave(post: Hashmap, checks: TreeCheck[]) { +// //OK, this whole tree thing is vulnerable to a critical attack +// //I drive myself crazy thinking of every single scenario. +// //Evil Villian: OK, let me make an Iframe that will load the +// // localhost page and then I will add a tree item +// // pointing to the C:/ drive and download the +// // registry and passwords. +// //https://security.stackexchange.com/a/29502/109521 +// let ch = checks.filter(e => Array.isArray(e) && e[1] === "TREETYPE_CHANGED"); +// let tt = typeof settings.tree === post.treeType; +// if (ch.length && checks.length === 1) { +// settings.tree = post.tree; +// eventer.emit("settings", settings); +// } +// } +// function typesFunction(defValue: any, keys: string[]) { +// // return `
                    ${keys.length > 1 ? `
                    ${keys[keys.length - 1]}
                    ` : ""}\n` +// // + Object.keys(defValue).map(e => `
                    ${e}
                    ${defValue[e].map(f => `
                    ${f}
                    `).join('')}
                    `).join('\n') +// // + `
                    ` +// return `
                    ${Object.keys(defValue).map(e => +// `
                    ${e}
                    ` +// )}
                    `; +// } diff --git a/src/server-types.js b/src/server-types.js index c01e441..c46552d 100644 --- a/src/server-types.js +++ b/src/server-types.js @@ -25,6 +25,61 @@ function init(eventer) { }); } exports.init = init; +function normalizeSettings(set, settingsFile) { + const settingsDir = path.dirname(settingsFile); + if (typeof set.tree === "object") + (function normalizeTree(item) { + keys(item).forEach(e => { + if (typeof item[e] === 'string') + item[e] = path.resolve(settingsDir, item[e]); + else if (typeof item[e] === 'object') + normalizeTree(item[e]); + else + throw 'Invalid item: ' + e + ': ' + item[e]; + }); + })(set.tree); + else + set.tree = path.resolve(settingsDir, set.tree); + if (set.backupDirectory) { + set.backupDirectory = path.resolve(settingsDir, set.backupDirectory); + } + if (!set.port) + set.port = 8080; + if (!set.host) + set.host = "127.0.0.1"; + if (!set.types) + set.types = { + "htmlfile": ["htm", "html"] + }; + if (!set.etag) + set.etag = ""; + if (!set.etagWindow) + set.etagWindow = 0; + if (!set.useTW5path) + set.useTW5path = false; + if (typeof set.debugLevel !== "number") + set.debugLevel = -1; + if (!set.allowNetwork) + set.allowNetwork = {}; + if (!set.allowNetwork.mkdir) + set.allowNetwork.mkdir = false; + if (!set.allowNetwork.upload) + set.allowNetwork.upload = false; + if (!set.allowNetwork.settings) + set.allowNetwork.settings = false; + if (!set.allowNetwork.WARNING_all_settings_WARNING) + set.allowNetwork.WARNING_all_settings_WARNING = false; + if (set.etag === "disabled" && !set.backupDirectory) + console.log("Etag checking is disabled, but a backup folder is not set. " + + "Changes made in multiple tabs/windows/browsers/computers can overwrite each " + + "other with stale information. SAVED WORK MAY BE LOST IF ANOTHER WINDOW WAS OPENED " + + "BEFORE THE WORK WAS SAVED. Instead of disabling Etag checking completely, you can " + + "also set the etagWindow setting to allow files to be modified if not newer than " + + "so many seconds from the copy being saved."); + set.__dirname = settingsDir; + set.__filename = settingsFile; +} +exports.normalizeSettings = normalizeSettings; function getHumanSize(size) { const TAGS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let power = 0; @@ -65,10 +120,12 @@ function tryParseJSON(str, errObj = {}) { } catch (e) { let err = new JsonError(findJSONError(e.message, str), e); - if (errObj === true) { + if (typeof errObj === "function") { + errObj(err); } - else + else { errObj.error = err; + } } } exports.tryParseJSON = tryParseJSON; @@ -163,10 +220,12 @@ function DebugLogger(prefix) { } let t = new Date(); let date = util_1.format('%s-%s-%s %s:%s:%s', t.getFullYear(), padLeft(t.getMonth() + 1, '00'), padLeft(t.getDate(), '00'), padLeft(t.getHours(), '00'), padLeft(t.getMinutes(), '00'), padLeft(t.getSeconds(), '00')); - console.log([' ', (msgLevel >= 3 ? (colors.BgRed + colors.FgWhite) : colors.FgRed) + prefix, - colors.FgCyan, date, colors.Reset, util_1.format.apply(null, args)].join(' ').split('\n').map((e, i) => { + console.log(' ' + + (msgLevel >= 3 ? (colors.BgRed + colors.FgWhite) : colors.FgRed) + prefix + + ' ' + colors.FgCyan + date + colors.Reset + + ' ' + util_1.format.apply(null, args).split('\n').map((e, i) => { if (i > 0) { - return new Array(28 + prefix.length).join(' ') + e; + return new Array(23 + prefix.length).join(' ') + e; } else { return e; @@ -223,7 +282,6 @@ exports.sanitizeJSON = sanitizeJSON; // })(); function serveFile(obs, file, root) { return obs.mergeMap(state => { - console.log('serving'); return exports.obs_stat(state)(path.join(root, file)).mergeMap(([err, stat]) => { if (err) return state.throw(404); @@ -277,14 +335,48 @@ function serveFolderIndex(options) { if (options.type === "json") { return function (state, res, folder) { readFolder(folder).subscribe(item => { - res.writeHead(200); - res.write(JSON.stringify(item)); - res.end(); + sendResponse(res, JSON.stringify(item), { + contentType: "application/json", + doGzip: canAcceptGzip(state.req) + }); }); }; } } exports.serveFolderIndex = serveFolderIndex; +function canAcceptGzip(header) { + if (((a) => typeof a === "object")(header)) { + header = header.headers['accept-encoding']; + } + var gzip = header.split(',').map(e => e.split(';')).filter(e => e[0] === "gzip")[0]; + return !!gzip && !!gzip[1] && parseFloat(gzip[1].split('=')[1]) > 0; +} +exports.canAcceptGzip = canAcceptGzip; +const zlib_1 = require("zlib"); +function sendResponse(res, body, options = {}) { + body = !Buffer.isBuffer(body) ? Buffer.from(body, 'utf8') : body; + if (options.doGzip) + zlib_1.gzip(body, (err, gzBody) => { + if (err) + _send(body, false); + else + _send(gzBody, true); + }); + else + _send(body, false); + function _send(body, isGzip) { + res.setHeader('Content-Length', Buffer.isBuffer(body) + ? body.length.toString() + : Buffer.byteLength(body, 'utf8').toString()); + if (isGzip) + res.setHeader('Content-Encoding', 'gzip'); + res.setHeader('Content-Type', options.contentType || 'text/plain; charset=utf-8'); + res.writeHead(200); + res.write(body); + res.end(); + } +} +exports.sendResponse = sendResponse; /** * Returns the keys and paths from the PathResolverResult directory. If there * is an error it will be sent directly to the client and nothing will be emitted. @@ -483,10 +575,13 @@ exports.obs_readFile = (tag = undefined) => (filepath, encoding) => new rx_1.Obs else fs.readFile(filepath, cb); }); -// Observable.bindCallback(fs.readFile, -// (err, data): NodeCallback => [err, data, state] as any -// ); -exports.obs_writeFile = (state) => rx_1.Observable.bindCallback(fs.writeFile, (err, data) => [err, data, state]); +// export type obs_writeFile_result = typeof obs_readFile_inner +exports.obs_writeFile = (tag = undefined) => (filepath, data) => new rx_1.Observable(subs => fs.writeFile(filepath, data, (err) => { + subs.next([err, tag, filepath]); + subs.complete(); +})); +// export const obs_writeFile = (state?: T) => Observable.bindCallback( +// fs.writeFile, (err, data): NodeCallback => [err, data, state] as any); class StateError extends Error { constructor(state, message) { super(message); @@ -601,13 +696,23 @@ class StateObject { }); this.res.end(); } - recieveBody() { - return recieveBody(this); + /** + * Recieves the body of the request and stores it in body and json. If there is an + * error parsing body as json, the error callback will be called or if the callback + * is boolean true it will send an error response with the json error position. + * + * @param {(true | ((e: JsonError) => void))} errorCB sends an error response + * showing the incorrect JSON syntax if true, or calls the function + * @returns {Observable} + * @memberof StateObject + */ + recieveBody(errorCB) { + return recieveBody(this, errorCB); } } exports.StateObject = StateObject; /** to be used with concatMap, mergeMap, etc. */ -function recieveBody(state) { +function recieveBody(state, sendError) { //get the data from the request return rx_1.Observable.fromEvent(state.req, 'data') .takeUntil(rx_1.Observable.fromEvent(state.req, 'end').take(1)) @@ -617,12 +722,14 @@ function recieveBody(state) { //console.log(state.body); if (state.body.length === 0) return state; - try { - state.json = JSON.parse(state.body); - } - catch (e) { - //state.json = buf; - } + let catchHandler = sendError === true ? (e) => { + state.res.writeHead(400, { + "Content-Type": "text/plain" + }); + state.res.write(e.errorPosition); + state.res.end(); + } : sendError; + state.json = tryParseJSON(state.body, catchHandler); return state; }); } diff --git a/src/server-types.ts b/src/server-types.ts index 01433dd..a7a8f10 100644 --- a/src/server-types.ts +++ b/src/server-types.ts @@ -26,6 +26,78 @@ export function init(eventer: EventEmitter) { }) } +export function normalizeSettings(set: ServerConfig, settingsFile) { + + const settingsDir = path.dirname(settingsFile); + + if (typeof set.tree === "object") + (function normalizeTree(item) { + keys(item).forEach(e => { + if (typeof item[e] === 'string') item[e] = path.resolve(settingsDir, item[e]); + else if (typeof item[e] === 'object') normalizeTree(item[e]); + else throw 'Invalid item: ' + e + ': ' + item[e]; + }) + })(set.tree); + else set.tree = path.resolve(settingsDir, set.tree); + + if (set.backupDirectory) { + set.backupDirectory = path.resolve(settingsDir, set.backupDirectory); + } + + if (!set.port) set.port = 8080; + if (!set.host) set.host = "127.0.0.1"; + if (!set.types) set.types = { + "htmlfile": ["htm", "html"] + } + if (!set.etag) set.etag = ""; + if (!set.etagWindow) set.etagWindow = 0; + if (!set.useTW5path) set.useTW5path = false; + if (typeof set.debugLevel !== "number") set.debugLevel = -1; + if (!set.allowNetwork) set.allowNetwork = {} as any; + if (!set.allowNetwork.mkdir) set.allowNetwork.mkdir = false; + if (!set.allowNetwork.upload) set.allowNetwork.upload = false; + if (!set.allowNetwork.settings) set.allowNetwork.settings = false; + if (!set.allowNetwork.WARNING_all_settings_WARNING) + set.allowNetwork.WARNING_all_settings_WARNING = false; + + if (set.etag === "disabled" && !set.backupDirectory) + console.log("Etag checking is disabled, but a backup folder is not set. " + + "Changes made in multiple tabs/windows/browsers/computers can overwrite each " + + "other with stale information. SAVED WORK MAY BE LOST IF ANOTHER WINDOW WAS OPENED " + + "BEFORE THE WORK WAS SAVED. Instead of disabling Etag checking completely, you can " + + "also set the etagWindow setting to allow files to be modified if not newer than " + + "so many seconds from the copy being saved."); + + set.__dirname = settingsDir; + set.__filename = settingsFile; +} + +interface ServerEventsListener { + (event: "websocket-connection", listener: (client: WebSocket, request: http.IncomingMessage) => void): THIS; + (event: "settingsChanged", listener: (keys: (keyof ServerConfig)[]) => void): THIS; + (event: "settings", listener: (settings: ServerConfig) => void): THIS; + (event: "stateError", listener: (state: StateObject) => void): THIS; +} +type ServerEvents = "websocket-connection" | "settingsChanged" | "settings"; +export interface ServerEventEmitter extends EventEmitter { + emit(event: "websocket-connection", client: WebSocket, request: http.IncomingMessage): boolean; + emit(event: "settingsChanged", keys: (keyof ServerConfig)[]): boolean; + emit(event: "settings", settings: ServerConfig): boolean; + emit(event: "stateError", state: StateObject): boolean; + + addListener: ServerEventsListener; + on: ServerEventsListener; //(event: keyof ServerEvents, listener: Function): this; + once: ServerEventsListener; //(event: keyof ServerEvents, listener: Function): this; + prependListener: ServerEventsListener; //(event: keyof ServerEvents, listener: Function): this; + prependOnceListener: ServerEventsListener; //(event: keyof ServerEvents, listener: Function): this; + removeListener: ServerEventsListener; //(event: keyof ServerEvents, listener: Function): this; + removeAllListeners(event?: ServerEvents): this; + setMaxListeners(n: number): this; + getMaxListeners(): number; + listeners(event: ServerEvents): Function[]; + eventNames(): (ServerEvents)[]; + listenerCount(type: ServerEvents): number; +} export function getHumanSize(size: number) { const TAGS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let power = 0; @@ -53,7 +125,7 @@ export interface Directory { type: string } -export function tryParseJSON(str: string, errObj: { error?: JsonError } | true = {}) { +export function tryParseJSON(str: string, errObj: { error?: JsonError } | ((e: JsonError) => void) = {}) { function findJSONError(message: string, json: string) { const res: string[] = []; const match = /position (\d+)/gi.exec(message); @@ -80,10 +152,11 @@ export function tryParseJSON(str: string, errObj: { error?: JsonError } | true = return JSON.parse(str); } catch (e) { let err = new JsonError(findJSONError(e.message, str), e) - if (errObj === true) { - - } else + if (typeof errObj === "function") { + errObj(err); + } else { errObj.error = err; + } } } export interface JsonErrorContainer { @@ -169,8 +242,7 @@ export namespace colors { * -3 - Request and response data for all messages (verbose) * -4 - Protocol details and full data dump (such as encryption steps and keys) */ -declare function DebugLog(level: number, err: NodeJS.ErrnoException); -declare function DebugLog(level: number, str: string, ...args: any[]); +declare function DebugLog(level: number, str: string | NodeJS.ErrnoException, ...args: any[]); // declare function DebugLog(str: string, ...args: any[]); export function isError(obj): obj is Error { return obj.constructor === Error; @@ -192,15 +264,16 @@ export function DebugLogger(prefix: string): typeof DebugLog { let t = new Date(); let date = format('%s-%s-%s %s:%s:%s', t.getFullYear(), padLeft(t.getMonth() + 1, '00'), padLeft(t.getDate(), '00'), padLeft(t.getHours(), '00'), padLeft(t.getMinutes(), '00'), padLeft(t.getSeconds(), '00')); - console.log([' ', (msgLevel >= 3 ? (colors.BgRed + colors.FgWhite) : colors.FgRed) + prefix, - colors.FgCyan, date, colors.Reset, format.apply(null, args)].join(' ').split('\n').map((e, i) => { + console.log(' ' + + (msgLevel >= 3 ? (colors.BgRed + colors.FgWhite) : colors.FgRed) + prefix + + ' ' + colors.FgCyan + date + colors.Reset + + ' ' + format.apply(null, args).split('\n').map((e, i) => { if (i > 0) { - return new Array(28 + prefix.length).join(' ') + e; + return new Array(23 + prefix.length).join(' ') + e; } else { return e; } }).join('\n')); - } as typeof DebugLog; } @@ -258,7 +331,6 @@ export interface ServeStaticResult { export function serveFile(obs: Observable, file: string, root: string) { return obs.mergeMap(state => { - console.log('serving'); return obs_stat(state)(path.join(root, file)).mergeMap(([err, stat]): any => { if (err) return state.throw(404); send(state.req, file, { root }) @@ -307,14 +379,44 @@ export function serveFolderIndex(options: { type: string }) { if (options.type === "json") { return function (state: StateObject, res: http.ServerResponse, folder: string) { readFolder(folder).subscribe(item => { - res.writeHead(200); - res.write(JSON.stringify(item)); - res.end(); + sendResponse(res, JSON.stringify(item), { + contentType: "application/json", + doGzip: canAcceptGzip(state.req) + }) }) } } } +export function canAcceptGzip(header: string | http.IncomingMessage) { + if (((a): a is http.IncomingMessage => typeof a === "object")(header)) { + header = header.headers['accept-encoding'] as string; + } + var gzip = header.split(',').map(e => e.split(';')).filter(e => e[0] === "gzip")[0]; + return !!gzip && !!gzip[1] && parseFloat(gzip[1].split('=')[1]) > 0 +} +import { gzip } from 'zlib'; +export function sendResponse(res: http.ServerResponse, body: Buffer | string, options: { + doGzip?: boolean, + contentType?: string +} = {}) { + body = !Buffer.isBuffer(body) ? Buffer.from(body, 'utf8') : body; + if (options.doGzip) gzip(body, (err, gzBody) => { + if (err) _send(body, false); + else _send(gzBody, true) + }); else _send(body, false); + + function _send(body, isGzip) { + res.setHeader('Content-Length', Buffer.isBuffer(body) + ? body.length.toString() + : Buffer.byteLength(body, 'utf8').toString()) + if (isGzip) res.setHeader('Content-Encoding', 'gzip'); + res.setHeader('Content-Type', options.contentType || 'text/plain; charset=utf-8'); + res.writeHead(200); + res.write(body); + res.end(); + } +} /** * Returns the keys and paths from the PathResolverResult directory. If there * is an error it will be sent directly to the client and nothing will be emitted. @@ -525,12 +627,18 @@ export const obs_readFile = (tag: T = undefined as any): obs_readFile_result< declare function obs_readFile_inner(filepath: string): Observable<[NodeJS.ErrnoException, Buffer, T, string]>; declare function obs_readFile_inner(filepath: string, encoding: string): Observable<[NodeJS.ErrnoException, string, T, string]>; -// Observable.bindCallback(fs.readFile, -// (err, data): NodeCallback => [err, data, state] as any -// ); -export const obs_writeFile = (state?: T) => Observable.bindCallback( - fs.writeFile, (err, data): NodeCallback => [err, data, state] as any); +// export type obs_writeFile_result = typeof obs_readFile_inner +export const obs_writeFile = (tag: T = undefined as any) => + (filepath: string, data: any) => new Observable<[NodeJS.ErrnoException | undefined, T, string]>(subs => + fs.writeFile(filepath, data, (err) => { + subs.next([err, tag, filepath]); + subs.complete(); + }) + ); + +// export const obs_writeFile = (state?: T) => Observable.bindCallback( +// fs.writeFile, (err, data): NodeCallback => [err, data, state] as any); export class StateError extends Error { @@ -619,7 +727,7 @@ export class StateObject { public req: http.IncomingMessage, public res: http.ServerResponse, private debugLog: typeof DebugLog, - private eventer: EventEmitter, + private eventer: ServerEventEmitter, public readonly isLocalHost: boolean = false ) { this.startTime = process.hrtime(); @@ -692,13 +800,23 @@ export class StateObject { }); this.res.end(); } - recieveBody() { - return recieveBody(this); + /** + * Recieves the body of the request and stores it in body and json. If there is an + * error parsing body as json, the error callback will be called or if the callback + * is boolean true it will send an error response with the json error position. + * + * @param {(true | ((e: JsonError) => void))} errorCB sends an error response + * showing the incorrect JSON syntax if true, or calls the function + * @returns {Observable} + * @memberof StateObject + */ + recieveBody(errorCB?: true | ((e: JsonError) => void)) { + return recieveBody(this, errorCB); } } /** to be used with concatMap, mergeMap, etc. */ -export function recieveBody(state: StateObject) { +export function recieveBody(state: StateObject, sendError?: true | ((e: JsonError) => void)) { //get the data from the request return Observable.fromEvent(state.req, 'data') //only take one since we only need one. this will dispose the listener @@ -711,11 +829,16 @@ export function recieveBody(state: StateObject) { //console.log(state.body); if (state.body.length === 0) return state; - try { - state.json = JSON.parse(state.body); - } catch (e) { - //state.json = buf; - } + + let catchHandler = sendError === true ? (e: JsonError) => { + state.res.writeHead(400, { + "Content-Type": "text/plain" + }); + state.res.write(e.errorPosition); + state.res.end(); + } : sendError; + + state.json = tryParseJSON(state.body, catchHandler); return state; }); } @@ -725,6 +848,7 @@ export interface ThrowFunc { export interface ServerConfig { __dirname: string; + __filename: string; __assetsDir: string; _disableLocalHost: boolean; tree: any, diff --git a/src/server.js b/src/server.js index b105fdc..8301164 100644 --- a/src/server.js +++ b/src/server.js @@ -46,57 +46,7 @@ var settings; } if (!settings.tree) throw "tree is not specified in the settings file"; -const settingsDir = path.dirname(settingsFile); -if (typeof settings.tree === "object") - (function normalizeTree(item) { - server_types_1.keys(item).forEach(e => { - if (typeof item[e] === 'string') - item[e] = path.resolve(settingsDir, item[e]); - else if (typeof item[e] === 'object') - normalizeTree(item[e]); - else - throw 'Invalid item: ' + e + ': ' + item[e]; - }); - })(settings.tree); -else - settings.tree = path.resolve(settingsDir, settings.tree); -if (settings.backupDirectory) { - settings.backupDirectory = path.resolve(settingsDir, settings.backupDirectory); -} -if (!settings.port) - settings.port = 8080; -if (!settings.host) - settings.host = "127.0.0.1"; -if (!settings.types) - settings.types = { - "htmlfile": ["htm", "html"] - }; -if (!settings.etag) - settings.etag = ""; -if (!settings.etagWindow) - settings.etagWindow = 0; -if (!settings.useTW5path) - settings.useTW5path = false; -if (typeof settings.debugLevel !== "number") - settings.debugLevel = -1; -if (!settings.allowNetwork) - settings.allowNetwork = {}; -if (!settings.allowNetwork.mkdir) - settings.allowNetwork.mkdir = false; -if (!settings.allowNetwork.upload) - settings.allowNetwork.upload = false; -if (!settings.allowNetwork.settings) - settings.allowNetwork.settings = false; -if (!settings.allowNetwork.WARNING_all_settings_WARNING) - settings.allowNetwork.WARNING_all_settings_WARNING = false; -if (settings.etag === "disabled" && !settings.backupDirectory) - console.log("Etag checking is disabled, but a backup folder is not set. " - + "Changes made in multiple tabs/windows/browsers/computers can overwrite each " - + "other with stale information. SAVED WORK MAY BE LOST IF ANOTHER WINDOW WAS OPENED " - + "BEFORE THE WORK WAS SAVED. Instead of disabling Etag checking completely, you can " - + "also set the etagWindow setting to allow files to be modified if not newer than " - + "so many seconds from the copy being saved."); -settings.__dirname = settingsDir; +server_types_1.normalizeSettings(settings, settingsFile); var ENV; (function (ENV) { ENV.disableLocalHost = false; diff --git a/src/server.ts b/src/server.ts index 0d700f8..2572d74 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,7 +12,9 @@ import { obs_stat, colors, obsTruthy, Hashmap, obs_readdir, serveFolder, serveFile, serveFolderIndex, init as initServerTypes, tryParseJSON, - JsonError + JsonError, + ServerEventEmitter, + normalizeSettings } from "./server-types"; import * as http from 'http' @@ -47,7 +49,7 @@ process.on('uncaughtException', err => { console.debug = function () { }; //noop console debug; //setup global objects -const eventer = new EventEmitter(); +const eventer = new EventEmitter() as ServerEventEmitter; const debug = DebugLogger('APP'); const logger = require('../lib/morgan.js').handler; @@ -69,49 +71,9 @@ var settings: ServerConfig; throw "The settings file could not be parsed: Invalid JSON"; } } -if (!settings.tree) throw "tree is not specified in the settings file"; - -const settingsDir = path.dirname(settingsFile); -if (typeof settings.tree === "object") - (function normalizeTree(item) { - keys(item).forEach(e => { - if (typeof item[e] === 'string') item[e] = path.resolve(settingsDir, item[e]); - else if (typeof item[e] === 'object') normalizeTree(item[e]); - else throw 'Invalid item: ' + e + ': ' + item[e]; - }) - })(settings.tree); -else settings.tree = path.resolve(settingsDir, settings.tree); - -if (settings.backupDirectory) { - settings.backupDirectory = path.resolve(settingsDir, settings.backupDirectory); -} - -if (!settings.port) settings.port = 8080; -if (!settings.host) settings.host = "127.0.0.1"; -if (!settings.types) settings.types = { - "htmlfile": ["htm", "html"] -} -if (!settings.etag) settings.etag = ""; -if (!settings.etagWindow) settings.etagWindow = 0; -if (!settings.useTW5path) settings.useTW5path = false; -if (typeof settings.debugLevel !== "number") settings.debugLevel = -1; -if (!settings.allowNetwork) settings.allowNetwork = {} as any; -if (!settings.allowNetwork.mkdir) settings.allowNetwork.mkdir = false; -if (!settings.allowNetwork.upload) settings.allowNetwork.upload = false; -if (!settings.allowNetwork.settings) settings.allowNetwork.settings = false; -if (!settings.allowNetwork.WARNING_all_settings_WARNING) - settings.allowNetwork.WARNING_all_settings_WARNING = false; - -if (settings.etag === "disabled" && !settings.backupDirectory) - console.log("Etag checking is disabled, but a backup folder is not set. " - + "Changes made in multiple tabs/windows/browsers/computers can overwrite each " - + "other with stale information. SAVED WORK MAY BE LOST IF ANOTHER WINDOW WAS OPENED " - + "BEFORE THE WORK WAS SAVED. Instead of disabling Etag checking completely, you can " - + "also set the etagWindow setting to allow files to be modified if not newer than " - + "so many seconds from the copy being saved."); - -settings.__dirname = settingsDir; +if (!settings.tree) throw "tree is not specified in the settings file"; +normalizeSettings(settings, settingsFile); namespace ENV { export let disableLocalHost: boolean = false; diff --git a/src/tiddlyserver.ts b/src/tiddlyserver.ts index b725f87..0c5cbea 100644 --- a/src/tiddlyserver.ts +++ b/src/tiddlyserver.ts @@ -3,7 +3,7 @@ import { StateObject, keys, ServerConfig, AccessPathResult, AccessPathTag, DirectoryEntry, Directory, sortBySelector, obs_stat, obs_readdir, FolderEntryType, obsTruthy, StatPathResult, DebugLogger, TreeObject, PathResolverResult, TreePathResult, resolvePath, - sendDirectoryIndex, getTreeItemFiles, statWalkPath, typeLookup, DirectoryIndexOptions, DirectoryIndexData + sendDirectoryIndex, getTreeItemFiles, statWalkPath, typeLookup, DirectoryIndexOptions, DirectoryIndexData, ServerEventEmitter } from "./server-types"; import * as fs from 'fs'; @@ -50,7 +50,7 @@ export function parsePath(path: string, jsonFile: string) { var settings: ServerConfig = {} as any; -export function init(eventer: EventEmitter) { +export function init(eventer: ServerEventEmitter) { eventer.on('settings', function (set: ServerConfig) { settings = set; }); diff --git a/tsconfig.json b/tsconfig.json index da064fe..85bfa6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,12 +10,12 @@ "inlineSourceMap": false }, "files": [ - "./src/server.ts", - "./assets/static/settings-page.ts" + "./src/server.ts" ], "exclude": [ "node_modules", "new-server", - "archive" + "archive", + "static" ] -} +} \ No newline at end of file