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: `
+
+ `
+ }
+
+ 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: `
+
+ `
+ }
+
+ 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: `
-
- `
- };
- 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: `
-
- `
- }
-
- 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\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\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 ``;
- }
- else if (item.valueType === "hashmapenum") {
- if (!item.valueOptions)
- return "";
- const dataTypes = item.valueOptions[0];
- const valueOptions = item.valueOptions[1];
- return ``;
- }
- else if (Object.keys(primitivesTypeMap).indexOf(item.valueType) > -1) {
- let type = primitivesTypeMap[item.valueType];
- return ``;
+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 `
-`;
+ 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 += ``;
- 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 ``;
+// } else if (item.valueType === "hashmapenum") {
+// if (!item.valueOptions) return "";
+// const dataTypes = item.valueOptions[0];
+// const valueOptions = item.valueOptions[1];
+// return ``;
+// } else if (Object.keys(primitivesTypeMap).indexOf(item.valueType) > -1) {
+// let type = primitivesTypeMap[item.valueType];
+// return ``;
+// } else if (item.valueType === "enum") {
+// if (!item.valueOptions) return "";
+// let options = item.valueOptions[1];
+// let type = item.valueOptions[0];
+// return `
+// `;
+// }
+// }
+// function treeGenerate(defValue: any, keys: string[]) {
+// let res = "";
+// let type = (val) =>
+// `onclick="this.form.elements.tree.disabled=true;" ${(typeof defValue === val ? "checked" : "")}`;
+// res += ``;
+// 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 ``;
- } else if (item.valueType === "hashmapenum") {
- if (!item.valueOptions) return "";
- const dataTypes = item.valueOptions[0];
- const valueOptions = item.valueOptions[1];
- return ``;
- } 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 ``;
+ 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 `
-`;
+ 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 += ``;
- 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 ``;
+// } else if (item.valueType === "hashmapenum") {
+// if (!item.valueOptions) return "";
+// const dataTypes = item.valueOptions[0];
+// const valueOptions = item.valueOptions[1];
+// return ``;
+// } else if (Object.keys(primitivesTypeMap).indexOf(item.valueType) > -1) {
+// let type = primitivesTypeMap[item.valueType];
+
+// return ``;
+
+// } else if (item.valueType === "enum") {
+// if (!item.valueOptions) return "";
+// let options = item.valueOptions[1];
+// let type = item.valueOptions[0];
+
+// return `
+// `;
+// }
+// }
+// function treeGenerate(defValue: any, keys: string[]) {
+// let res = "";
+// let type = (val) =>
+// `onclick="this.form.elements.tree.disabled=true;" ${(typeof defValue === val ? "checked" : "")}`;
+
+// res += ``;
+// 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