From 6a7612ddf8859cf1994b52b4ea9e9478ec59bf50 Mon Sep 17 00:00:00 2001 From: webplusai Date: Wed, 30 Oct 2024 18:59:44 +0100 Subject: [PATCH] MWS authentication (#8596) * mws authentication * add more tests and permission checkers * add logic to ensure that only authenticated users' requests are handled * add custom login page * Implement user authentication as well as session handling * work on user operations authorization * add middleware to route handlers for bags & tiddlers routes * add feature that only returns the tiddlers and bags which the user has permission to access on index page * refactor auth routes & added user management page * fix Ci Test failure issue * fix users list page, add manage roles page * add commands and scripts to create new user & assign roles and permissions * resolved ci-test failure * add ACL permissions to bags & tiddlers on creation * fix comments and access control list bug * fix indentation issues * working on user profile edit * remove list users command & added support for database in server options * implement user profile update and password change feature * update plugin readme * implement command which triggers protected mode on the server * revert server-wide auth flag. Implement selective authorization * ACL management feature * Complete Access control list implementation * Added support to manage users' assigned role by admin * fix comments * fix comment --- core/language/en-GB/Buttons.multids | 2 + core/modules/widgets/navigator.js | 11 +- core/ui/PageControls/manage-acl.tid | 15 + core/wiki/tags/PageControls.tid | 2 +- editions/multiwikiserver/tiddlywiki.info | 18 +- package-lock.json | 26 +- package.json | 5 +- .../multiwikiclient/managetiddleraction.js | 65 ++ .../multiwikiserver/auth/authentication.js | 43 + .../multiwikiserver/auth/form/login.tid | 19 + .../auth/form/login/error-message.tid | 7 + .../multiwikiserver/auth/form/login/form.tid | 8 + .../multiwikiserver/auth/form/login/head.tid | 3 + .../auth/form/login/header.tid | 3 + .../auth/form/login/styles.tid | 48 ++ .../multiwikiserver/docs/readme.tid | 96 +++ .../modules/commands/mws-add-permission.js | 49 ++ .../modules/commands/mws-add-role.js | 49 ++ .../modules/commands/mws-add-user.js | 58 ++ .../commands/mws-assign-role-permission.js | 62 ++ .../modules/commands/mws-assign-user-role.js | 59 ++ .../modules/commands/mws-test-server.js | 25 +- .../multiwikiserver/modules/mws-server.js | 99 ++- .../routes/handlers/change-user-password.js | 60 ++ .../modules/routes/handlers/delete-acl.js | 41 + .../routes/handlers/delete-bag-tiddler.js | 25 +- .../modules/routes/handlers/delete-role.js | 57 ++ .../modules/routes/handlers/get-acl.js | 97 +++ .../routes/handlers/get-bag-tiddler-blob.js | 11 +- .../routes/handlers/get-bag-tiddler.js | 35 +- .../modules/routes/handlers/get-bag.js | 54 +- .../modules/routes/handlers/get-index.js | 14 +- .../modules/routes/handlers/get-login.js | 39 + .../routes/handlers/get-recipe-tiddler.js | 41 +- .../handlers/get-recipe-tiddlers-json.js | 30 +- .../modules/routes/handlers/get-users.js | 54 ++ .../modules/routes/handlers/get-wiki.js | 4 + .../modules/routes/handlers/manage-roles.js | 36 + .../modules/routes/handlers/manage-user.js | 68 ++ .../modules/routes/handlers/post-acl.js | 64 ++ .../routes/handlers/post-bag-tiddlers.js | 52 +- .../modules/routes/handlers/post-bag.js | 4 + .../modules/routes/handlers/post-login.js | 67 ++ .../modules/routes/handlers/post-logout.js | 37 + .../modules/routes/handlers/post-recipe.js | 4 + .../modules/routes/handlers/post-role.js | 36 + .../modules/routes/handlers/post-user.js | 63 ++ .../modules/routes/handlers/put-bag.js | 12 +- .../routes/handlers/put-recipe-tiddler.js | 39 +- .../modules/routes/handlers/put-recipe.js | 24 +- .../routes/handlers/update-user-profile.js | 47 ++ .../modules/routes/helpers/acl-middleware.js | 79 ++ .../modules/store/sql-tiddler-database.js | 795 +++++++++++++++++- .../store/tests-sql-tiddler-database.js | 118 +++ .../modules/tests/test-attachment.js | 270 +++--- .../multiwikiserver/scripts/admin-dropdown.js | 19 + .../templates/add-user-form.tid | 93 ++ .../multiwikiserver/templates/get-index.tid | 83 +- .../multiwikiserver/templates/get-users.tid | 124 +++ .../multiwikiserver/templates/manage-acl.tid | 267 ++++++ .../templates/manage-roles.tid | 175 ++++ .../templates/manage-user-account.tid | 111 +++ .../multiwikiserver/templates/manage-user.tid | 158 ++++ .../multiwikiserver/templates/mws-header.tid | 94 +++ 64 files changed, 3966 insertions(+), 307 deletions(-) create mode 100644 core/ui/PageControls/manage-acl.tid create mode 100644 plugins/tiddlywiki/multiwikiclient/managetiddleraction.js create mode 100644 plugins/tiddlywiki/multiwikiserver/auth/authentication.js create mode 100644 plugins/tiddlywiki/multiwikiserver/auth/form/login.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/auth/form/login/error-message.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/auth/form/login/form.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/auth/form/login/head.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/auth/form/login/header.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/auth/form/login/styles.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-permission.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-role.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-user.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-role-permission.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-user-role.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/change-user-password.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-acl.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-role.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-acl.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-login.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-users.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/manage-roles.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/manage-user.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-acl.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-login.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-logout.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-role.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-user.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/update-user-profile.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js create mode 100644 plugins/tiddlywiki/multiwikiserver/scripts/admin-dropdown.js create mode 100644 plugins/tiddlywiki/multiwikiserver/templates/add-user-form.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/templates/get-users.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/templates/manage-acl.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/templates/manage-roles.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/templates/manage-user-account.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/templates/manage-user.tid create mode 100644 plugins/tiddlywiki/multiwikiserver/templates/mws-header.tid diff --git a/core/language/en-GB/Buttons.multids b/core/language/en-GB/Buttons.multids index 2fa732fd9..1ec3ae204 100644 --- a/core/language/en-GB/Buttons.multids +++ b/core/language/en-GB/Buttons.multids @@ -159,6 +159,8 @@ ListBullet/Caption: bulleted list ListBullet/Hint: Apply bulleted list formatting to lines containing selection ListNumber/Caption: numbered list ListNumber/Hint: Apply numbered list formatting to lines containing selection +ManageACL/Caption: manage access control +ManageACL/Hint: Manage access control configuration for this wiki MonoBlock/Caption: monospaced block MonoBlock/Hint: Apply monospaced block formatting to lines containing selection MonoLine/Caption: monospaced diff --git a/core/modules/widgets/navigator.js b/core/modules/widgets/navigator.js index e9bec7682..f36afd191 100755 --- a/core/modules/widgets/navigator.js +++ b/core/modules/widgets/navigator.js @@ -44,7 +44,8 @@ NavigatorWidget.prototype.render = function(parent,nextSibling) { {type: "tm-fold-tiddler", handler: "handleFoldTiddlerEvent"}, {type: "tm-fold-other-tiddlers", handler: "handleFoldOtherTiddlersEvent"}, {type: "tm-fold-all-tiddlers", handler: "handleFoldAllTiddlersEvent"}, - {type: "tm-unfold-all-tiddlers", handler: "handleUnfoldAllTiddlersEvent"} + {type: "tm-unfold-all-tiddlers", handler: "handleUnfoldAllTiddlersEvent"}, + {type: "tm-manage-acl", handler: "handleManageACLTiddlersEvent"} ]); this.parentDomNode = parent; this.computeAttributes(); @@ -635,6 +636,14 @@ NavigatorWidget.prototype.handleUnfoldAllTiddlersEvent = function(event) { }); }; +NavigatorWidget.prototype.handleManageACLTiddlersEvent = function() { + var pathname = window.location.pathname; + var paths = pathname.split("/"); + var recipeName = paths[paths.length - 1]; + var bagName = document.querySelector("h1.tc-site-title").innerHTML; + window.location.href = "/admin/acl/"+recipeName+"/"+bagName +}; + exports.navigator = NavigatorWidget; })(); diff --git a/core/ui/PageControls/manage-acl.tid b/core/ui/PageControls/manage-acl.tid new file mode 100644 index 000000000..c9e2bb25f --- /dev/null +++ b/core/ui/PageControls/manage-acl.tid @@ -0,0 +1,15 @@ +title: $:/core/ui/Buttons/manage-acl +tags: $:/tags/PageControls +caption: {{$:/core/images/globe}} {{$:/language/Buttons/ManageACL/Caption}} +description: {{$:/language/Buttons/ManageACL/Hint}} + +\whitespace trim +<$button tooltip={{$:/language/Buttons/ManageACL/Hint}} aria-label={{$:/language/Buttons/ManageACL/Caption}} class=<>> +<$action-managetiddler tiddler=<>/> +{{$:/core/images/globe}} +<%if [match[yes]] %> + +<$text text={{$:/language/Buttons/ManageACL/Caption}}/> + +<%endif%> + diff --git a/core/wiki/tags/PageControls.tid b/core/wiki/tags/PageControls.tid index c0f1cb233..82af6c66a 100644 --- a/core/wiki/tags/PageControls.tid +++ b/core/wiki/tags/PageControls.tid @@ -1,2 +1,2 @@ title: $:/tags/PageControls -list: [[$:/core/ui/Buttons/home]] [[$:/core/ui/Buttons/close-all]] [[$:/core/ui/Buttons/fold-all]] [[$:/core/ui/Buttons/unfold-all]] [[$:/core/ui/Buttons/permaview]] [[$:/core/ui/Buttons/new-tiddler]] [[$:/core/ui/Buttons/new-journal]] [[$:/core/ui/Buttons/new-image]] [[$:/core/ui/Buttons/import]] [[$:/core/ui/Buttons/export-page]] [[$:/core/ui/Buttons/control-panel]] [[$:/core/ui/Buttons/advanced-search]] [[$:/core/ui/Buttons/manager]] [[$:/core/ui/Buttons/tag-manager]] [[$:/core/ui/Buttons/language]] [[$:/core/ui/Buttons/palette]] [[$:/core/ui/Buttons/theme]] [[$:/core/ui/Buttons/layout]] [[$:/core/ui/Buttons/storyview]] [[$:/core/ui/Buttons/encryption]] [[$:/core/ui/Buttons/timestamp]] [[$:/core/ui/Buttons/full-screen]] [[$:/core/ui/Buttons/print]] [[$:/core/ui/Buttons/save-wiki]] [[$:/core/ui/Buttons/refresh]] [[$:/core/ui/Buttons/network-activity]] [[$:/core/ui/Buttons/more-page-actions]] +list: [[$:/core/ui/Buttons/home]] [[$:/core/ui/Buttons/close-all]] [[$:/core/ui/Buttons/fold-all]] [[$:/core/ui/Buttons/unfold-all]] [[$:/core/ui/Buttons/permaview]] [[$:/core/ui/Buttons/new-tiddler]] [[$:/core/ui/Buttons/new-journal]] [[$:/core/ui/Buttons/new-image]] [[$:/core/ui/Buttons/import]] [[$:/core/ui/Buttons/export-page]] [[$:/core/ui/Buttons/control-panel]] [[$:/core/ui/Buttons/advanced-search]] [[$:/core/ui/Buttons/manager]] [[$:/core/ui/Buttons/tag-manager]] [[$:/core/ui/Buttons/language]] [[$:/core/ui/Buttons/palette]] [[$:/core/ui/Buttons/theme]] [[$:/core/ui/Buttons/layout]] [[$:/core/ui/Buttons/storyview]] [[$:/core/ui/Buttons/encryption]] [[$:/core/ui/Buttons/manage-acl]] [[$:/core/ui/Buttons/timestamp]] [[$:/core/ui/Buttons/full-screen]] [[$:/core/ui/Buttons/print]] [[$:/core/ui/Buttons/save-wiki]] [[$:/core/ui/Buttons/refresh]] [[$:/core/ui/Buttons/network-activity]] [[$:/core/ui/Buttons/more-page-actions]] diff --git a/editions/multiwikiserver/tiddlywiki.info b/editions/multiwikiserver/tiddlywiki.info index 20a683c78..abc3e460d 100644 --- a/editions/multiwikiserver/tiddlywiki.info +++ b/editions/multiwikiserver/tiddlywiki.info @@ -11,6 +11,15 @@ "tiddlywiki/snowwhite" ], "build": { + "mws-add-user": [ + "--mws-add-permission", "READ", "Allows user to create tiddlers", + "--mws-add-permission", "WRITE", "Gives the user the permission to edit and delete tiddlers", + "--mws-add-role", "ADMIN", "System Administrator", + "--mws-assign-role-permission", "ADMIN", "READ", + "--mws-assign-role-permission", "ADMIN", "WRITE", + "--mws-add-user", "user", "pass123", + "--mws-assign-user-role", "user", "ADMIN" + ], "load-mws-demo-data": [ "--mws-load-wiki-folder","./editions/tw5.com","docs", "TiddlyWiki Documentation from https://tiddlywiki.com","docs","TiddlyWiki Documentation from https://tiddlywiki.com", "--mws-load-wiki-folder","./editions/dev","dev","TiddlyWiki Developer Documentation from https://tiddlywiki.com/dev","dev-docs", "TiddlyWiki Developer Documentation from https://tiddlywiki.com/dev", @@ -25,7 +34,14 @@ "--mws-save-tiddler-text","bag-alpha","$:/SiteTitle","bag-alpha", "--mws-save-tiddler-text","bag-alpha","😀😃😄😁😆🥹😅😂","bag-alpha", "--mws-save-tiddler-text","bag-beta","$:/SiteTitle","bag-beta", - "--mws-save-tiddler-text","bag-gamma","$:/SiteTitle","bag-gamma" + "--mws-save-tiddler-text","bag-gamma","$:/SiteTitle","bag-gamma", + "--mws-add-permission", "READ", "Allows user to create tiddlers", + "--mws-add-permission", "WRITE", "Gives the user the permission to edit and delete tiddlers", + "--mws-add-role", "ADMIN", "System Administrator", + "--mws-add-role", "USER", "Basic User", + "--mws-assign-role-permission", "ADMIN", "READ", + "--mws-assign-role-permission", "ADMIN", "WRITE", + "--mws-assign-role-permission", "USER", "READ" ] } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b83166c5b..1470e745e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,10 @@ "version": "5.3.6-prerelease", "license": "BSD", "dependencies": { - "@playwright/test": "^1.46.1", + "@playwright/test": "^1.47.2", "better-sqlite3": "^9.4.3", "node-sqlite3-wasm": "^0.8.10", - "playwright": "^1.46.1" + "playwright": "^1.47.2" }, "bin": { "tiddlywiki": "tiddlywiki.js" @@ -177,11 +177,11 @@ "dev": true }, "node_modules/@playwright/test": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.1.tgz", - "integrity": "sha512-Fq6SwLujA/DOIvNC2EL/SojJnkKf/rAwJ//APpJJHRyMi1PdKrY3Az+4XNQ51N4RTbItbIByQ0jgd1tayq1aeA==", + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.2.tgz", + "integrity": "sha512-jTXRsoSPONAs8Za9QEQdyjFn+0ZQFjCiIztAIF6bi1HqhBzG9Ma7g1WotyiGqFSBRZjIEqMdT8RUlbk1QVhzCQ==", "dependencies": { - "playwright": "1.46.1" + "playwright": "1.47.2" }, "bin": { "playwright": "cli.js" @@ -1205,11 +1205,11 @@ } }, "node_modules/playwright": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.1.tgz", - "integrity": "sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==", + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.2.tgz", + "integrity": "sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==", "dependencies": { - "playwright-core": "1.46.1" + "playwright-core": "1.47.2" }, "bin": { "playwright": "cli.js" @@ -1222,9 +1222,9 @@ } }, "node_modules/playwright-core": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.1.tgz", - "integrity": "sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==", + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.2.tgz", + "integrity": "sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==", "bin": { "playwright-core": "cli.js" }, diff --git a/package.json b/package.json index 9654e2e4e..b80276ceb 100644 --- a/package.json +++ b/package.json @@ -35,14 +35,15 @@ "start": "node ./tiddlywiki.js ./editions/multiwikiserver --mws-load-plugin-bags --build load-mws-demo-data --mws-listen", "build:test-edition": "node ./tiddlywiki.js ./editions/test --verbose --version --build index", "test:multiwikiserver-edition": "node ./tiddlywiki.js ./editions/multiwikiserver/ --build load-mws-demo-data --mws-listen --mws-test-server http://127.0.0.1:8080/ --quit", + "mws-add-user": "node ./tiddlywiki.js ./editions/multiwikiserver --build load-mws-demo-data --mws-listen --build mws-add-user --quit", "test": "npm run build:test-edition && npm run test:multiwikiserver-edition", "lint:fix": "eslint . --fix", "lint": "eslint ." }, "dependencies": { - "@playwright/test": "^1.46.1", + "@playwright/test": "^1.47.2", "better-sqlite3": "^9.4.3", "node-sqlite3-wasm": "^0.8.10", - "playwright": "^1.46.1" + "playwright": "^1.47.2" } } diff --git a/plugins/tiddlywiki/multiwikiclient/managetiddleraction.js b/plugins/tiddlywiki/multiwikiclient/managetiddleraction.js new file mode 100644 index 000000000..b3d1c98ea --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/managetiddleraction.js @@ -0,0 +1,65 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiclient/managetiddleraction.js +type: application/javascript +module-type: widget + +A widget to manage tiddler actions. +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +var Widget = require("$:/core/modules/widgets/widget.js").widget; + +var ManageTiddlerAction = function(parseTreeNode,options) { + this.initialise(parseTreeNode,options); +}; + +/* +Inherit from the base widget class +*/ +ManageTiddlerAction.prototype = new Widget(); + +/* +Render this widget into the DOM +*/ +ManageTiddlerAction.prototype.render = function(parent,nextSibling) { + this.computeAttributes(); + this.execute(); +}; + +/* +Compute the internal state of the widget +*/ +ManageTiddlerAction.prototype.execute = function() { + this.tiddler = this.getAttribute("tiddler"); +}; + +/* +Invoke the action associated with this widget +*/ +ManageTiddlerAction.prototype.invokeAction = function(triggeringWidget,event) { + var pathname = window.location.pathname; + var paths = pathname.split("/"); + var recipeName = paths[paths.length - 1]; + var bagName = document.querySelector("h1.tc-site-title").innerHTML; + window.location.href = "/admin/acl/"+recipeName+"/"+bagName; +}; + +/* +Refresh the widget by ensuring our attributes are up to date +*/ +ManageTiddlerAction.prototype.refresh = function(changedTiddlers) { + var changedAttributes = this.computeAttributes(); + if(changedAttributes.tiddler) { + this.refreshSelf(); + return true; + } + return this.refreshChildren(changedTiddlers); +}; + +exports["action-managetiddler"] = ManageTiddlerAction; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/auth/authentication.js b/plugins/tiddlywiki/multiwikiserver/auth/authentication.js new file mode 100644 index 000000000..2c3a16987 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/authentication.js @@ -0,0 +1,43 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/authentication.js +type: application/javascript +module-type: library + +Handles authentication related operations + +\*/ + +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +var crypto = require("crypto"); + +function Authenticator(database) { + if(!(this instanceof Authenticator)) { + return new Authenticator(database); + } + this.sqlTiddlerDatabase = database; +} + +Authenticator.prototype.verifyPassword = function(inputPassword, storedHash) { + var hashedInput = this.hashPassword(inputPassword); + return hashedInput === storedHash; +}; + +Authenticator.prototype.hashPassword = function(password) { + return crypto.createHash("sha256").update(password).digest("hex"); +}; + +Authenticator.prototype.createSession = function(userId) { + var sessionId = crypto.randomBytes(16).toString("hex"); + // Store the session in your database or in-memory store + this.sqlTiddlerDatabase.createOrUpdateUserSession(userId, sessionId); + return sessionId; +}; + +exports.Authenticator = Authenticator; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login.tid new file mode 100644 index 000000000..0cb03657a --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login.tid @@ -0,0 +1,19 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login +tags: $:/tags/ServerRoute +route-method: GET +route-path: /login + +<$transclude tiddler="$:/plugins/tiddlywiki/multiwikiserver/auth/form/login/styles"/> + + + + <$transclude tiddler="$:/plugins/tiddlywiki/multiwikiserver/auth/form/login/head"/> + + + + + \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login/error-message.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login/error-message.tid new file mode 100644 index 000000000..53b663ae6 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login/error-message.tid @@ -0,0 +1,7 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/error-message + +<$list filter="[[$:/temp/mws/login/error]!is[missing]]" variable="errorTiddler"> +
+ {{$:/temp/mws/login/error}} +
+ \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login/form.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login/form.tid new file mode 100644 index 000000000..2dcd1e89d --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login/form.tid @@ -0,0 +1,8 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/form + + diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login/head.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login/head.tid new file mode 100644 index 000000000..b5df2aa5a --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login/head.tid @@ -0,0 +1,3 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/head + +TiddlyWiki Login \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login/header.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login/header.tid new file mode 100644 index 000000000..d6e75df8e --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login/header.tid @@ -0,0 +1,3 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/header + +

TiddlyWiki Login

\ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login/styles.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login/styles.tid new file mode 100644 index 000000000..c4905227c --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login/styles.tid @@ -0,0 +1,48 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/styles + + \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/docs/readme.tid b/plugins/tiddlywiki/multiwikiserver/docs/readme.tid index dfde5a25a..7feb676a4 100644 --- a/plugins/tiddlywiki/multiwikiserver/docs/readme.tid +++ b/plugins/tiddlywiki/multiwikiserver/docs/readme.tid @@ -33,3 +33,99 @@ To run the tests: ``` ./bin/test.sh ``` + +# Authentication & Authorization + +## Overview + +Our application has transitioned from a conventional username/password authentication system to a more robust Authentication and Authorization implementation. This new system supports multiple user accounts, roles, permissions, and a comprehensive access control list. + +## Key Features + +1. Multiple User Accounts +2. Role-based Access Control +3. Granular Permissions +4. Access Control List (ACL) + +## Running the App +By default, the Multiwiki Server starts up without the need for authentication. An admin user is created by default with the username `user` and password `pass123`. +This user has the `ADMIN` role which grants full access to all recipes. This user is automatically assigned the `READ` and `WRITE` permissions. +You can create additional users and roles through the admin interface. + +Access control is enforced at the recipe level. A user can read or write to a recipe if they have the `READ` or `WRITE` permission for that recipe. +Roles can be assigned to users and can have the `READ` and `WRITE` permissions assigned to them. + +Guest users have no permissions and can only access recipes that do not have an ACL assigned to them. + +To start the app, run the following command: + +```bash +npm run start +``` + + +## Setting Up the Admin User + +To initialize the system with an admin user, use the following command: + +```bash +npm run mws-add-user +``` + +This command performs the following actions: +- Creates a default admin user with the credentials: + - Username: `user` + - Password: `pass123` +- Establishes `READ` and `WRITE` permissions +- Creates an `ADMIN` role +- Assigns `READ` and `WRITE` permissions to the `ADMIN` role +- Assigns the `ADMIN` role to the newly created user + +## User Management + +Users can be managed through the application interface or via command-line tools. Each user account consists of: +- Unique username +- Securely hashed password +- Assigned role(s) + +## Roles and Permissions + +### Roles + +Roles are used to group sets of permissions. The default setup includes an `ADMIN` role, but additional roles can be created as needed (e.g., `USER`, `MANAGER`). + +### Permissions + +Permissions define specific actions that can be performed. The default permissions are: +- `READ`: Allows viewing of resources +- `WRITE`: Allows creation, modification, and deletion of resources + +Additional permissions can be created to suit specific application needs. + +## Access Control List (ACL) + +The ACL maintains a mapping between roles, permissions, and entities (such as recipes or bags). This allows for fine-grained control over who can perform what actions on which resources. + +## Authentication Flow + +1. User provides credentials (username and password) +2. System verifies credentials against stored user data +3. If valid, a session is created for the user + +## Authorization Flow + +1. User attempts to perform an action on a resource +2. System checks the user's role +3. System verifies if the role has the required permission for the action +4. System checks the ACL to ensure the role has permission for the specific entity +5. If all checks pass, the action is allowed; otherwise, it's denied + +## Extending the System + +To add new roles, permissions, or modify the ACL: + +1. Use the provided administrative interface or CLI tools +2. Update the ACL structure in the application's configuration +3. Implement new permission checks in the relevant parts of the application + +By following this documentation, you can effectively manage and utilize the new Authentication and Authorization system in your application. \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-permission.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-permission.js new file mode 100644 index 000000000..ecaa13ba5 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-permission.js @@ -0,0 +1,49 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-add-permission.js +type: application/javascript +module-type: command + +Command to create a permission + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-add-permission", + synchronous: false +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + + if(this.params.length < 2) { + return "Usage: --mws-add-permission "; + } + + if(!$tw.mws || !$tw.mws.store || !$tw.mws.store.sqlTiddlerDatabase) { + return "Error: MultiWikiServer or SQL database not initialized."; + } + + var permission_name = this.params[0]; + var description = this.params[1]; + + $tw.mws.store.sqlTiddlerDatabase.createPermission(permission_name, description); + + console.log(permission_name+" Permission Created Successfully!") + self.callback(); + return null; +}; + +exports.Command = Command; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-role.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-role.js new file mode 100644 index 000000000..19ba2af9a --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-role.js @@ -0,0 +1,49 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-add-role.js +type: application/javascript +module-type: command + +Command to create a role + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-add-role", + synchronous: false +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + + if(this.params.length < 2) { + return "Usage: --mws-add-role "; + } + + if(!$tw.mws || !$tw.mws.store || !$tw.mws.store.sqlTiddlerDatabase) { + return "Error: MultiWikiServer or SQL database not initialized."; + } + + var role_name = this.params[0]; + var description = this.params[1]; + + $tw.mws.store.sqlTiddlerDatabase.createRole(role_name, description); + + console.log(role_name+" Role Created Successfully!") + self.callback(null, "Role Created Successfully!"); + return null; +}; + +exports.Command = Command; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-user.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-user.js new file mode 100644 index 000000000..4c3a4168a --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-user.js @@ -0,0 +1,58 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-add-user.js +type: application/javascript +module-type: command + +Command to create users and grant permission + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; +if($tw.node) { + var crypto = require("crypto"); +} +exports.info = { + name: "mws-add-user", + synchronous: false +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + + if(this.params.length < 2) { + return "Usage: --mws-add-user [email]"; + } + + if(!$tw.mws || !$tw.mws.store || !$tw.mws.store.sqlTiddlerDatabase) { + return "Error: MultiWikiServer or SQL database not initialized."; + } + + var username = this.params[0]; + var password = this.params[1]; + var email = this.params[2] || username + "@example.com"; + var hashedPassword = crypto.createHash("sha256").update(password).digest("hex"); + + var user = $tw.mws.store.sqlTiddlerDatabase.getUserByUsername(username); + + if(user) { + self.callback("WARNING: An account with the username (" + username + ") already exists"); + } else { + $tw.mws.store.sqlTiddlerDatabase.createUser(username, email, hashedPassword); + console.log("User Account Created Successfully!") + self.callback(); + } + return null; +}; + +exports.Command = Command; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-role-permission.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-role-permission.js new file mode 100644 index 000000000..621a46e59 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-role-permission.js @@ -0,0 +1,62 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-assign-role-permission.js +type: application/javascript +module-type: command + +Command to assign permission to a role + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-assign-role-permission", + synchronous: false +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + + if(this.params.length < 2) { + return "Usage: --mws-assign-role-permission "; + } + + if(!$tw.mws || !$tw.mws.store || !$tw.mws.store.sqlTiddlerDatabase) { + return "Error: MultiWikiServer or SQL database not initialized."; + } + + var role_name = this.params[0]; + var permission_name = this.params[1]; + var role = $tw.mws.store.sqlTiddlerDatabase.getRoleByName(role_name); + var permission = $tw.mws.store.sqlTiddlerDatabase.getPermissionByName(permission_name); + + if(!role) { + return "Error: Unable to find Role: "+role_name; + } + + if(!permission) { + return "Error: Unable to find Permission: "+permission_name; + } + + var permission = $tw.mws.store.sqlTiddlerDatabase.getPermissionByName(permission_name); + + + $tw.mws.store.sqlTiddlerDatabase.addPermissionToRole(role.role_id, permission.permission_id); + + console.log(permission_name+" permission assigned to "+role_name+" role successfully!") + self.callback(); + return null; +}; + +exports.Command = Command; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-user-role.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-user-role.js new file mode 100644 index 000000000..2657dbdd3 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-user-role.js @@ -0,0 +1,59 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-assign-user-role.js +type: application/javascript +module-type: command + +Command to assign a role to a user + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-assign-user-role", + synchronous: false +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + + if(this.params.length < 2) { + return "Usage: --mws-assign-user-role "; + } + + if(!$tw.mws || !$tw.mws.store || !$tw.mws.store.sqlTiddlerDatabase) { + return "Error: MultiWikiServer or SQL database not initialized."; + } + + var username = this.params[0]; + var role_name = this.params[1]; + var role = $tw.mws.store.sqlTiddlerDatabase.getRoleByName(role_name); + var user = $tw.mws.store.sqlTiddlerDatabase.getUserByUsername(username); + + if(!role) { + return "Error: Unable to find Role: "+role_name; + } + + if(!user) { + return "Error: Unable to find user with the username "+username; + } + + $tw.mws.store.sqlTiddlerDatabase.addRoleToUser(user.user_id, role.role_id); + + console.log(role_name+" role has been assigned to user with username "+username) + self.callback(); + return null; +}; + +exports.Command = Command; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-test-server.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-test-server.js index 680a26326..0c246c93e 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-test-server.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-test-server.js @@ -50,11 +50,18 @@ TestRunner.prototype.runTests = function(callback) { const self = this; let currentTestSpec = 0; let hasFailed = false; + let sessionId; function runNextTest() { if(currentTestSpec < testSpecs.length) { const testSpec = testSpecs[currentTestSpec]; + if(!!sessionId) { + testSpec.headers['Cookie'] = `session=${sessionId}; HttpOnly; Path=/`; + } currentTestSpec += 1; - self.runTest(testSpec,function(err) { + self.runTest(testSpec,function(err, data) { + if(data?.sessionId) { + sessionId = data?.sessionId; + } if(err) { hasFailed = true; console.log(`Failed "${testSpec.description}" with "${err}"`) @@ -96,7 +103,7 @@ TestRunner.prototype.runTest = function(testSpec,callback) { response.on("end", () => { const jsonData = $tw.utils.parseJSONSafe(buffer,function() {return undefined;}); const testResult = testSpec.expectedResult(jsonData,buffer,response.headers); - callback(testResult ? null : "Test failed"); + callback(testResult ? null : "Test failed", jsonData); }); }); request.on("error", (e) => { @@ -112,6 +119,20 @@ TestRunner.prototype.runTest = function(testSpec,callback) { }; const testSpecs = [ + { + description: "Login Test User", + method: "POST", + path: "/login", + headers: { + "Accept": 'application/json', + "Content-Type": 'application/x-www-form-urlencoded', + "User-Agent": 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' + }, + data: "username=user&password=pass123", + expectedResult: (jsonData,data,headers) => { + return !!jsonData.sessionId; + } + }, { description: "Check index page", method: "GET", diff --git a/plugins/tiddlywiki/multiwikiserver/modules/mws-server.js b/plugins/tiddlywiki/multiwikiserver/modules/mws-server.js index 8650502a5..e594cd146 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/mws-server.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/mws-server.js @@ -19,7 +19,8 @@ if($tw.node) { path = require("path"), querystring = require("querystring"), crypto = require("crypto"), - zlib = require("zlib"); + zlib = require("zlib"), + aclMiddleware = require('$:/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js').middleware; } /* @@ -34,6 +35,7 @@ function Server(options) { this.authenticators = options.authenticators || []; this.wiki = options.wiki; this.boot = options.boot || $tw.boot; + this.sqlTiddlerDatabase = options.sqlTiddlerDatabase || $tw.mws.store.sqlTiddlerDatabase; // Initialise the variables this.variables = $tw.utils.extend({},this.defaultVariables); if(options.variables) { @@ -157,9 +159,10 @@ function sendResponse(request,response,statusCode,headers,data,encoding) { data = zlib.gzipSync(data); } } - - response.writeHead(statusCode,headers); - response.end(data,encoding); + if(!response.headersSent) { + response.writeHead(statusCode,headers); + response.end(data,encoding); + } } function redirect(request,response,statusCode,location) { @@ -350,6 +353,13 @@ Server.prototype.methodMappings = { "DELETE": "writers" }; +Server.prototype.methodACLPermMappings = { + "GET": "READ", + "PUT": "WRITE", + "POST": "WRITE", + "DELETE": "WRITE" +} + /* Check whether a given user is authorized for the specified authorizationType ("readers" or "writers"). Pass null or undefined as the username to check for anonymous access */ @@ -358,9 +368,57 @@ Server.prototype.isAuthorized = function(authorizationType,username) { return principals.indexOf("(anon)") !== -1 || (username && (principals.indexOf("(authenticated)") !== -1 || principals.indexOf(username) !== -1)); } +Server.prototype.parseCookieString = function(cookieString) { + const cookies = {}; + if (typeof cookieString !== 'string') return cookies; + + cookieString.split(';').forEach(cookie => { + const parts = cookie.split('='); + if (parts.length >= 2) { + const key = parts[0].trim(); + const value = parts.slice(1).join('=').trim(); + cookies[key] = decodeURIComponent(value); + } + }); + + return cookies; +} + +Server.prototype.authenticateUser = function(request, response) { + const {session: session_id} = this.parseCookieString(request.headers.cookie) + if (!session_id) { + return false; + } + // get user info + const user = this.sqlTiddlerDatabase.findUserBySessionId(session_id); + if (!user) { + return false + } + delete user.password; + const userRole = this.sqlTiddlerDatabase.getUserRoles(user.user_id); + user['isAdmin'] = userRole?.role_name?.toLowerCase() === 'admin' + + return user +}; + +Server.prototype.requestAuthentication = function(response) { + if(!response.headersSent) { + response.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="Secure Area"' + }); + response.end('Authentication required.'); + } +}; + + Server.prototype.requestHandler = function(request,response,options) { options = options || {}; const queryString = require("querystring"); + + // Authenticate the user + const authenticatedUser = this.authenticateUser(request, response); + const authenticatedUsername = authenticatedUser?.username; + // Compose the state object var self = this; var state = {}; @@ -374,43 +432,52 @@ Server.prototype.requestHandler = function(request,response,options) { state.redirect = redirect.bind(self,request,response); state.streamMultipartData = streamMultipartData.bind(self,request); state.makeTiddlerEtag = makeTiddlerEtag.bind(self); + state.authenticatedUser = authenticatedUser; + state.authenticatedUsername = authenticatedUsername; + // Get the principals authorized to access this resource state.authorizationType = options.authorizationType || this.methodMappings[request.method] || "readers"; + // Check whether anonymous access is granted - state.allowAnon = this.isAuthorized(state.authorizationType,null); - // Authenticate with the first active authenticator - if(this.authenticators.length > 0) { - if(!this.authenticators[0].authenticateRequest(request,response,state)) { - // Bail if we failed (the authenticator will have sent the response) - return; - } - } + state.allowAnon = false; //this.isAuthorized(state.authorizationType,null); + // Authorize with the authenticated username - if(!this.isAuthorized(state.authorizationType,state.authenticatedUsername)) { - response.writeHead(401,"'" + state.authenticatedUsername + "' is not authorized to access '" + this.servername + "'"); + if(!this.isAuthorized(state.authorizationType,state.authenticatedUsername) && !response.headersSent) { + response.writeHead(403,"'" + state.authenticatedUsername + "' is not authorized to access '" + this.servername + "'"); response.end(); return; } + // Find the route that matches this path var route = self.findMatchingRoute(request,state); + + // If the route is configured to use ACL middleware, check that the user has permission + if(route?.useACL) { + const permissionName = this.methodACLPermMappings[route.method]; + aclMiddleware(request,response,state,route.entityName,permissionName) + } + // Optionally output debug info if(self.get("debug-level") !== "none") { console.log("Request path:",JSON.stringify(state.urlInfo)); console.log("Request headers:",JSON.stringify(request.headers)); console.log("authenticatedUsername:",state.authenticatedUsername); } + // Return a 404 if we didn't find a route - if(!route) { + if(!route && !response.headersSent) { response.writeHead(404); response.end(); return; } + // If this is a write, check for the CSRF header unless globally disabled, or disabled for this route - if(!this.csrfDisable && !route.csrfDisable && state.authorizationType === "writers" && request.headers["x-requested-with"] !== "TiddlyWiki") { + if(!this.csrfDisable && !route.csrfDisable && state.authorizationType === "writers" && request.headers["x-requested-with"] !== "TiddlyWiki" && !response.headersSent) { response.writeHead(403,"'X-Requested-With' header required to login to '" + this.servername + "'"); response.end(); return; } + if (response.headersSent) return; // Receive the request body if necessary and hand off to the route handler if(route.bodyFormat === "stream" || request.method === "GET" || request.method === "HEAD") { // Let the route handle the request stream itself diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/change-user-password.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/change-user-password.js new file mode 100644 index 000000000..f44aafd39 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/change-user-password.js @@ -0,0 +1,60 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/change-password.js +type: application/javascript +module-type: mws-route + +POST /change-user-password + +\*/ +(function () { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; +var authenticator = require("$:/plugins/tiddlywiki/multiwikiserver/auth/authentication.js").Authenticator; + +exports.method = "POST"; + +exports.path = /^\/change-user-password\/?$/; + +exports.bodyFormat = "www-form-urlencoded"; + +exports.csrfDisable = true; + +exports.handler = function (request, response, state) { + if(!state.authenticatedUser) { + response.writeHead(401, "Unauthorized", { "Content-Type": "text/plain" }); + response.end("Unauthorized"); + return; + } + var auth = authenticator(state.server.sqlTiddlerDatabase); + + var userId = state.authenticatedUser.user_id; + var newPassword = state.data.newPassword; + var confirmPassword = state.data.confirmPassword; + + if(newPassword !== confirmPassword) { + response.setHeader("Set-Cookie", "flashMessage=New passwords do not match; Path=/; HttpOnly; Max-Age=5"); + response.writeHead(302, { "Location": "/admin/users/" + userId }); + response.end(); + return; + } + + var userData = state.server.sqlTiddlerDatabase.getUser(userId); + + if(!userData) { + response.setHeader("Set-Cookie", "flashMessage=User not found; Path=/; HttpOnly; Max-Age=5"); + response.writeHead(302, { "Location": "/admin/users/" + userId }); + response.end(); + return; + } + + var newHash = auth.hashPassword(newPassword); + var result = state.server.sqlTiddlerDatabase.updateUserPassword(userId, newHash); + + response.setHeader("Set-Cookie", `flashMessage=${result.message}; Path=/; HttpOnly; Max-Age=5`); + response.writeHead(302, { "Location": "/admin/users/" + userId }); + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-acl.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-acl.js new file mode 100644 index 000000000..a4c4768e4 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-acl.js @@ -0,0 +1,41 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/delete-acl.js +type: application/javascript +module-type: mws-route + +POST /admin/delete-acl + +\*/ +(function () { + + /*jslint node: true, browser: true */ + /*global $tw: false */ + "use strict"; + + var aclMiddleware = require("$:/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js").middleware; + + exports.method = "POST"; + + exports.path = /^\/admin\/delete-acl\/?$/; + + + exports.bodyFormat = "www-form-urlencoded"; + + exports.csrfDisable = true; + + exports.handler = function (request, response, state) { + var sqlTiddlerDatabase = state.server.sqlTiddlerDatabase; + var recipe_name = state.data.recipe_name; + var bag_name = state.data.bag_name; + var acl_id = state.data.acl_id; + var entity_type = state.data.entity_type; + + aclMiddleware(request, response, state, entity_type, "WRITE"); + + sqlTiddlerDatabase.deleteACL(acl_id); + + response.writeHead(302, { "Location": "/admin/acl/" + recipe_name + "/" + bag_name }); + response.end(); + }; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-bag-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-bag-tiddler.js index 4c895cde7..722ef2d8c 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-bag-tiddler.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-bag-tiddler.js @@ -12,25 +12,32 @@ DELETE /bags/:bag_name/tiddler/:title /*global $tw: false */ "use strict"; +var aclMiddleware = require("$:/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js").middleware; + exports.method = "DELETE"; exports.path = /^\/bags\/([^\/]+)\/tiddlers\/(.+)$/; exports.handler = function(request,response,state) { + aclMiddleware(request, response, state, "bag", "WRITE"); // Get the parameters var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]), title = $tw.utils.decodeURIComponentSafe(state.params[1]); if(bag_name) { - var result = $tw.mws.store.deleteTiddler(title,bag_name); - response.writeHead(204, "OK", { - "X-Revision-Number": result.tiddler_id.toString(), - Etag: state.makeTiddlerEtag(result), - "Content-Type": "text/plain" - }); - response.end(); + if(!response.headersSent) { + var result = $tw.mws.store.deleteTiddler(title,bag_name); + response.writeHead(204, "OK", { + "X-Revision-Number": result.tiddler_id.toString(), + Etag: state.makeTiddlerEtag(result), + "Content-Type": "text/plain" + }); + response.end(); + } } else { - response.writeHead(404); - response.end(); + if(!response.headersSent) { + response.writeHead(404); + response.end(); + } } }; diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-role.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-role.js new file mode 100644 index 000000000..3cc81e593 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-role.js @@ -0,0 +1,57 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/delete-role.js +type: application/javascript +module-type: mws-route + +POST /admin/delete-role + +\*/ +(function () { + + /*jslint node: true, browser: true */ + /*global $tw: false */ + "use strict"; + + exports.method = "POST"; + + exports.path = /^\/admin\/delete-role\/?$/; + + exports.bodyFormat = "www-form-urlencoded"; + + exports.csrfDisable = true; + + exports.handler = function (request, response, state) { + var sqlTiddlerDatabase = state.server.sqlTiddlerDatabase; + var role_id = state.data.role_id; + + if(!state.authenticatedUser || !state.authenticatedUser.isAdmin) { + response.writeHead(403, "Forbidden"); + response.end(); + return; + } + + // Check if the role exists + var role = sqlTiddlerDatabase.getRoleById(role_id); + if(!role) { + response.writeHead(404, "Not Found"); + response.end("Role not found"); + return; + } + + // Check if the role is in use + var isRoleInUse = sqlTiddlerDatabase.isRoleInUse(role_id); + if(isRoleInUse) { + response.writeHead(400, "Bad Request"); + response.end("Cannot delete role as it is still in use"); + return; + } + + // Delete the role + sqlTiddlerDatabase.deleteRole(role_id); + + // Redirect back to the roles management page + response.writeHead(302, { "Location": "/admin/roles" }); + response.end(); + }; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-acl.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-acl.js new file mode 100644 index 000000000..fefcff8d2 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-acl.js @@ -0,0 +1,97 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-acl.js +type: application/javascript +module-type: mws-route + +GET /admin/acl + +\*/ +(function () { + /*jslint node: true, browser: true */ + /*global $tw: false */ + "use strict"; + + exports.method = "GET"; + + exports.path = /^\/admin\/acl\/(.+)$/; + + exports.handler = function (request, response, state) { + var sqlTiddlerDatabase = state.server.sqlTiddlerDatabase; + var params = state.params[0].split("/") + var recipeName = params[0]; + var bagName = params[params.length - 1]; + + var recipes = sqlTiddlerDatabase.listRecipes() + var bags = sqlTiddlerDatabase.listBags() + + var recipe = recipes.find((entry) => entry.recipe_name === recipeName && entry.bag_names.includes(bagName)) + var bag = bags.find((entry) => entry.bag_name === bagName); + + if (!recipe || !bag) { + response.writeHead(500, "Unable to handle request", { "Content-Type": "text/html" }); + response.end(); + return; + } + + var recipeAclRecords = sqlTiddlerDatabase.getEntityAclRecords(recipe.recipe_name); + var bagAclRecords = sqlTiddlerDatabase.getEntityAclRecords(bag.bag_name); + var roles = state.server.sqlTiddlerDatabase.listRoles(); + var permissions = state.server.sqlTiddlerDatabase.listPermissions(); + + // This ensures that the user attempting to view the ACL management page has permission to do so + if(!state.authenticatedUser || (recipeAclRecords.length > 0 && !sqlTiddlerDatabase.hasRecipePermission(state.authenticatedUser.user_id, recipeName, 'WRITE'))){ + response.writeHead(403, "Forbidden"); + response.end(); + return + } + + // Enhance ACL records with role and permission details + recipeAclRecords = recipeAclRecords.map(record => { + var role = roles.find(role => role.role_id === record.role_id); + var permission = permissions.find(perm => perm.permission_id === record.permission_id); + return ({ + ...record, + role, + permission, + role_name: role.role_name, + role_description: role.description, + permission_name: permission.permission_name, + permission_description: permission.description + }) + }); + + bagAclRecords = bagAclRecords.map(record => { + var role = roles.find(role => role.role_id === record.role_id); + var permission = permissions.find(perm => perm.permission_id === record.permission_id); + return ({ + ...record, + role, + permission, + role_name: role.role_name, + role_description: role.description, + permission_name: permission.permission_name, + permission_description: permission.description + }) + }); + + response.writeHead(200, "OK", { "Content-Type": "text/html" }); + + var html = $tw.mws.store.adminWiki.renderTiddler("text/plain", "$:/plugins/tiddlywiki/multiwikiserver/templates/page", { + variables: { + "page-content": "$:/plugins/tiddlywiki/multiwikiserver/templates/manage-acl", + "roles-list": JSON.stringify(roles), + "permissions-list": JSON.stringify(permissions), + "bag": JSON.stringify(bag), + "recipe": JSON.stringify(recipe), + "recipe-acl-records": JSON.stringify(recipeAclRecords), + "bag-acl-records": JSON.stringify(bagAclRecords), + "username": state.authenticatedUser ? state.authenticatedUser.username : "Guest", + "user-is-admin": state.authenticatedUser && state.authenticatedUser.isAdmin ? "yes" : "no" + } + }); + + response.write(html); + response.end(); + }; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler-blob.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler-blob.js index 50daf1ec4..28d23212c 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler-blob.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler-blob.js @@ -12,17 +12,20 @@ GET /bags/:bag_name/tiddler/:title/blob /*global $tw: false */ "use strict"; +var aclMiddleware = require("$:/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js").middleware; + exports.method = "GET"; exports.path = /^\/bags\/([^\/]+)\/tiddlers\/([^\/]+)\/blob$/; exports.handler = function(request,response,state) { + aclMiddleware(request, response, state, "bag", "READ"); // Get the parameters const bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]), title = $tw.utils.decodeURIComponentSafe(state.params[1]); if(bag_name) { const result = $tw.mws.store.getBagTiddlerStream(title,bag_name); - if(result) { + if(result && !response.headersSent) { response.writeHead(200, "OK",{ Etag: state.makeTiddlerEtag(result), "Content-Type": result.type, @@ -31,8 +34,10 @@ exports.handler = function(request,response,state) { return; } } - response.writeHead(404); - response.end(); + if (!response.headersSent) { + response.writeHead(404); + response.end(); + } }; }()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler.js index 6030ea99e..52b169652 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler.js @@ -16,11 +16,14 @@ fallback= // Optional redirect if the tiddler is not found /*global $tw: false */ "use strict"; +var aclMiddleware = require("$:/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js").middleware; + exports.method = "GET"; exports.path = /^\/bags\/([^\/]+)\/tiddlers\/(.+)$/; exports.handler = function(request,response,state) { + aclMiddleware(request, response, state, "bag", "READ"); // Get the parameters const bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]), title = $tw.utils.decodeURIComponentSafe(state.params[1]), @@ -37,29 +40,37 @@ exports.handler = function(request,response,state) { // This is not a JSON API request, we should return the raw tiddler content const result = $tw.mws.store.getBagTiddlerStream(title,bag_name); if(result) { - response.writeHead(200, "OK",{ - Etag: state.makeTiddlerEtag(result), - "Content-Type": result.type - }); + if(!response.headersSent){ + response.writeHead(200, "OK",{ + Etag: state.makeTiddlerEtag(result), + "Content-Type": result.type + }); + } result.stream.pipe(response); return; } else { - response.writeHead(404); - response.end(); + if(!response.headersSent){ + response.writeHead(404); + response.end(); + } return; } } } else { // Redirect to fallback URL if tiddler not found if(state.queryParameters.fallback) { - response.writeHead(302, "OK",{ - "Location": state.queryParameters.fallback - }); - response.end(); + if (!response.headersSent){ + response.writeHead(302, "OK",{ + "Location": state.queryParameters.fallback + }); + response.end(); + } return; } else { - response.writeHead(404); - response.end(); + if(!response.headersSent){ + response.writeHead(404); + response.end(); + } return; } } diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag.js index 49df49f4a..7d262b83f 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag.js @@ -7,7 +7,7 @@ GET /bags/:bag_name/ GET /bags/:bag_name \*/ -(function() { +(function () { /*jslint node: true, browser: true */ /*global $tw: false */ @@ -17,38 +17,46 @@ exports.method = "GET"; exports.path = /^\/bags\/([^\/]+)(\/?)$/; -exports.handler = function(request,response,state) { +exports.useACL = true; + +exports.entityName = "bag" + +exports.handler = function (request, response, state) { // Redirect if there is no trailing slash. We do this so that the relative URL specified in the upload form works correctly - if(state.params[1] !== "/") { - state.redirect(301,state.urlInfo.path + "/"); + if (state.params[1] !== "/") { + state.redirect(301, state.urlInfo.path + "/"); return; } // Get the parameters var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]), bagTiddlers = bag_name && $tw.mws.store.getBagTiddlers(bag_name); - if(bag_name && bagTiddlers) { + if (bag_name && bagTiddlers) { // If application/json is requested then this is an API request, and gets the response in JSON - if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) { - state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(bagTiddlers),"utf8"); + if (request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) { + state.sendResponse(200, { "Content-Type": "application/json" }, JSON.stringify(bagTiddlers), "utf8"); } else { - // This is not a JSON API request, we should return the raw tiddler content - response.writeHead(200, "OK",{ - "Content-Type": "text/html" - }); - var html = $tw.mws.store.adminWiki.renderTiddler("text/plain","$:/plugins/tiddlywiki/multiwikiserver/templates/page",{ - variables: { - "page-content": "$:/plugins/tiddlywiki/multiwikiserver/templates/get-bag", - "bag-name": bag_name, - "bag-titles": JSON.stringify(bagTiddlers.map(bagTiddler => bagTiddler.title)), - "bag-tiddlers": JSON.stringify(bagTiddlers) - } - }); - response.write(html); - response.end(); + if (!response.headersSent) { + // This is not a JSON API request, we should return the raw tiddler content + response.writeHead(200, "OK", { + "Content-Type": "text/html" + }); + var html = $tw.mws.store.adminWiki.renderTiddler("text/plain", "$:/plugins/tiddlywiki/multiwikiserver/templates/page", { + variables: { + "page-content": "$:/plugins/tiddlywiki/multiwikiserver/templates/get-bag", + "bag-name": bag_name, + "bag-titles": JSON.stringify(bagTiddlers.map(bagTiddler => bagTiddler.title)), + "bag-tiddlers": JSON.stringify(bagTiddlers) + } + }); + response.write(html); + response.end(); + } } } else { - response.writeHead(404); - response.end(); + if (!response.headersSent) { + response.writeHead(404); + response.end(); + } } }; diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-index.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-index.js index 8aca75fa1..fc40e9852 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-index.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-index.js @@ -19,7 +19,9 @@ exports.path = /^\/$/; exports.handler = function(request,response,state) { // Get the bag and recipe information var bagList = $tw.mws.store.listBags(), - recipeList = $tw.mws.store.listRecipes(); + recipeList = $tw.mws.store.listRecipes(), + sqlTiddlerDatabase = state.server.sqlTiddlerDatabase; + // If application/json is requested then this is an API request, and gets the response in JSON if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) { state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(recipes),"utf8"); @@ -28,13 +30,19 @@ exports.handler = function(request,response,state) { response.writeHead(200, "OK",{ "Content-Type": "text/html" }); + // filter bags and recipies by user's read access from ACL + var allowedRecipes = recipeList.filter(recipe => sqlTiddlerDatabase.hasRecipePermission(state.authenticatedUser?.user_id, recipe.recipe_name, 'READ')); + var allowedBags = bagList.filter(bag => sqlTiddlerDatabase.hasBagPermission(state.authenticatedUser?.user_id, bag.bag_name, 'READ')); + // Render the html var html = $tw.mws.store.adminWiki.renderTiddler("text/plain","$:/plugins/tiddlywiki/multiwikiserver/templates/page",{ variables: { "show-system": state.queryParameters.show_system || "off", "page-content": "$:/plugins/tiddlywiki/multiwikiserver/templates/get-index", - "bag-list": JSON.stringify(bagList), - "recipe-list": JSON.stringify(recipeList) + "bag-list": JSON.stringify(allowedBags), + "recipe-list": JSON.stringify(allowedRecipes), + "username": state.authenticatedUser ? state.authenticatedUser.username : "Guest", + "user-is-admin": state.authenticatedUser && state.authenticatedUser.isAdmin ? "yes" : "no" } }); response.write(html); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-login.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-login.js new file mode 100644 index 000000000..dd0421a66 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-login.js @@ -0,0 +1,39 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-login.js +type: application/javascript +module-type: mws-route + +GET /login + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "GET"; + +exports.path = /^\/login$/; + +exports.handler = function(request,response,state) { + // Check if the user already has a valid session + var authenticatedUser = state.server.authenticateUser(request, response); + if(authenticatedUser) { + // User is already logged in, redirect to home page + response.writeHead(302, { "Location": "/" }); + response.end(); + return; + } + var loginTiddler = $tw.mws.store.adminWiki.getTiddler("$:/plugins/tiddlywiki/multiwikiserver/auth/form/login"); + if(loginTiddler) { + var text = $tw.mws.store.adminWiki.renderTiddler("text/html", loginTiddler.fields.title); + response.writeHead(200, { "Content-Type": "text/html" }); + response.end(text); + } else { + response.writeHead(404); + response.end("Login page not found"); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddler.js index 391554aec..8b23184ad 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddler.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddler.js @@ -20,6 +20,10 @@ exports.method = "GET"; exports.path = /^\/recipes\/([^\/]+)\/tiddlers\/(.+)$/; +exports.useACL = true; + +exports.entityName = "recipe" + exports.handler = function(request,response,state) { // Get the parameters var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]), @@ -38,27 +42,32 @@ exports.handler = function(request,response,state) { } else { // This is not a JSON API request, we should return the raw tiddler content var type = tiddlerInfo.tiddler.type || "text/plain"; - response.writeHead(200, "OK",{ + if(!response.headersSent) { + response.writeHead(200, "OK",{ Etag: state.makeTiddlerEtag(tiddlerInfo), - "Content-Type": type - }); - response.write(tiddlerInfo.tiddler.text || "",($tw.config.contentTypeInfo[type] ||{encoding: "utf8"}).encoding); - response.end();; + "Content-Type": type + }); + response.write(tiddlerInfo.tiddler.text || "",($tw.config.contentTypeInfo[type] ||{encoding: "utf8"}).encoding); + response.end(); + } return; } } else { - // Redirect to fallback URL if tiddler not found - if(state.queryParameters.fallback) { - response.writeHead(302, "OK",{ - "Location": state.queryParameters.fallback - }); - response.end(); - return; - } else { - response.writeHead(404); - response.end(); - return; + if(!response.headersSent) { + // Redirect to fallback URL if tiddler not found + if(state.queryParameters.fallback) { + response.writeHead(302, "OK",{ + "Location": state.queryParameters.fallback + }); + response.end(); + return; + } else { + response.writeHead(404); + response.end(); + return; + } } + return; } }; diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddlers-json.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddlers-json.js index f30088971..e16e3d10a 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddlers-json.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddlers-json.js @@ -17,22 +17,24 @@ exports.method = "GET"; exports.path = /^\/recipes\/([^\/]+)\/tiddlers.json$/; exports.handler = function(request,response,state) { - // Get the parameters - var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]); - if(recipe_name) { - // Get the tiddlers in the recipe, optionally since the specified last known tiddler_id - var recipeTiddlers = $tw.mws.store.getRecipeTiddlers(recipe_name,{ - include_deleted: state.queryParameters.include_deleted === "true", - last_known_tiddler_id: state.queryParameters.last_known_tiddler_id - }); - if(recipeTiddlers) { - state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(recipeTiddlers),"utf8"); - return; + if(!response.headersSent) { + // Get the parameters + var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]); + if(recipe_name) { + // Get the tiddlers in the recipe, optionally since the specified last known tiddler_id + var recipeTiddlers = $tw.mws.store.getRecipeTiddlers(recipe_name,{ + include_deleted: state.queryParameters.include_deleted === "true", + last_known_tiddler_id: state.queryParameters.last_known_tiddler_id + }); + if(recipeTiddlers) { + state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(recipeTiddlers),"utf8"); + return; + } } + // Fail if something went wrong + response.writeHead(404); + response.end(); } - // Fail if something went wrong - response.writeHead(404); - response.end(); }; diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-users.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-users.js new file mode 100644 index 000000000..a0df877bd --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-users.js @@ -0,0 +1,54 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-users.js +type: application/javascript +module-type: mws-route + +GET /admin/users + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "GET"; + +exports.path = /^\/admin\/users$/; + +exports.handler = function(request,response,state) { + var userList = state.server.sqlTiddlerDatabase.listUsers(); + + // Ensure userList is an array + if (!Array.isArray(userList)) { + userList = []; + console.error("userList is not an array"); + } + + // Convert dates to strings and ensure all necessary fields are present + userList = userList.map(user => ({ + user_id: user.user_id || '', + username: user.username || '', + email: user.email || '', + created_at: user.created_at ? new Date(user.created_at).toISOString() : '', + last_login: user.last_login ? new Date(user.last_login).toISOString() : '' + })); + + response.writeHead(200, "OK", { + "Content-Type": "text/html" + }); + + // Render the html + var html = $tw.mws.store.adminWiki.renderTiddler("text/plain","$:/plugins/tiddlywiki/multiwikiserver/templates/page",{ + variables: { + "page-content": "$:/plugins/tiddlywiki/multiwikiserver/templates/get-users", + "user-list": JSON.stringify(userList), + "username": state.authenticatedUser ? state.authenticatedUser.username : "Guest", + "user-is-admin": state.authenticatedUser && state.authenticatedUser.isAdmin ? "yes" : "no" + } + }); + response.write(html); + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-wiki.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-wiki.js index 8361cc1ae..1765f5e20 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-wiki.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-wiki.js @@ -16,6 +16,10 @@ exports.method = "GET"; exports.path = /^\/wiki\/([^\/]+)$/; +exports.useACL = true; + +exports.entityName = "recipe" + exports.handler = function(request,response,state) { // Get the recipe name from the parameters var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]), diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/manage-roles.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/manage-roles.js new file mode 100644 index 000000000..00a7a31be --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/manage-roles.js @@ -0,0 +1,36 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/manage-roles.js +type: application/javascript +module-type: mws-route + +GET /admin/manage-roles + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "GET"; + +exports.path = /^\/admin\/roles\/?$/; + +exports.handler = function(request, response, state) { + var roles = state.server.sqlTiddlerDatabase.listRoles(); + + response.writeHead(200, "OK", {"Content-Type": "text/html"}); + + var html = $tw.mws.store.adminWiki.renderTiddler("text/plain", "$:/plugins/tiddlywiki/multiwikiserver/templates/page", { + variables: { + "page-content": "$:/plugins/tiddlywiki/multiwikiserver/templates/manage-roles", + "roles-list": JSON.stringify(roles), + "username": state.authenticatedUser ? state.authenticatedUser.username : "Guest", + "user-is-admin": state.authenticatedUser && state.authenticatedUser.isAdmin ? "yes" : "no" + } + }); + response.write(html); + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/manage-user.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/manage-user.js new file mode 100644 index 000000000..8bd40ba9c --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/manage-user.js @@ -0,0 +1,68 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/manage-user.js +type: application/javascript +module-type: mws-route + +GET /admin/users/:user_id + +\*/ +(function() { + + /*jslint node: true, browser: true */ + /*global $tw: false */ + "use strict"; + + exports.method = "GET"; + + exports.path = /^\/admin\/users\/([^\/]+)\/?$/; + + exports.handler = function(request,response,state) { + var user_id = $tw.utils.decodeURIComponentSafe(state.params[0]); + var userData = state.server.sqlTiddlerDatabase.getUser(user_id); + + if(!userData) { + response.writeHead(404, "Not Found", {"Content-Type": "text/html"}); + var errorHtml = $tw.mws.store.adminWiki.renderTiddler("text/plain", "$:/plugins/tiddlywiki/multiwikiserver/templates/error", { + variables: { + "error-message": "User not found" + } + }); + response.write(errorHtml); + response.end(); + return; + } + + // Convert dates to strings and ensure all necessary fields are present + const user = { + user_id: userData.user_id || '', + username: userData.username || '', + email: userData.email || '', + created_at: userData.created_at ? new Date(userData.created_at).toISOString() : '', + last_login: userData.last_login ? new Date(userData.last_login).toISOString() : '' + }; + + // Get all roles which the user has been assigned + var userRole = state.server.sqlTiddlerDatabase.getUserRoles(user_id); + var allRoles = state.server.sqlTiddlerDatabase.listRoles(); + + response.writeHead(200, "OK", { + "Content-Type": "text/html" + }); + + // Render the html + var html = $tw.mws.store.adminWiki.renderTiddler("text/plain", "$:/plugins/tiddlywiki/multiwikiserver/templates/page", { + variables: { + "page-content": "$:/plugins/tiddlywiki/multiwikiserver/templates/manage-user", + "user": JSON.stringify(user), + "user-role": JSON.stringify(userRole), + "all-roles": JSON.stringify(allRoles), + "is-current-user-profile": state.authenticatedUser && state.authenticatedUser.user_id === $tw.utils.parseInt(user_id, 10) ? "yes" : "no", + "username": state.authenticatedUser ? state.authenticatedUser.username : "Guest", + "user-is-admin": state.authenticatedUser && state.authenticatedUser.isAdmin ? "yes" : "no" + } + }); + response.write(html); + response.end(); + }; + + }()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-acl.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-acl.js new file mode 100644 index 000000000..ae772360e --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-acl.js @@ -0,0 +1,64 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-acl.js +type: application/javascript +module-type: mws-route + +POST /admin/post-acl + +\*/ +(function () { + + /*jslint node: true, browser: true */ + /*global $tw: false */ + "use strict"; + + exports.method = "POST"; + + exports.path = /^\/admin\/post-acl\/?$/; + + + exports.bodyFormat = "www-form-urlencoded"; + + exports.csrfDisable = true; + + exports.handler = function (request, response, state) { + var sqlTiddlerDatabase = state.server.sqlTiddlerDatabase; + var entity_type = state.data.entity_type; + var recipe_name = state.data.recipe_name; + var bag_name = state.data.bag_name; + var role_id = state.data.role_id; + var permission_id = state.data.permission_id; + var isRecipe = entity_type === "recipe" + + var entityAclRecords = sqlTiddlerDatabase.getACLByName(entity_type, isRecipe ? recipe_name : bag_name, true); + + var aclExists = entityAclRecords.some((record) => ( + record.role_id == role_id && record.permission_id == permission_id + )) + + // This ensures that the user attempting to modify the ACL has permission to do so + // if(!state.authenticatedUser || (entityAclRecords.length > 0 && !sqlTiddlerDatabase[isRecipe ? 'hasRecipePermission' : 'hasBagPermission'](state.authenticatedUser.user_id, isRecipe ? recipe_name : bag_name, 'WRITE'))){ + // response.writeHead(403, "Forbidden"); + // response.end(); + // return + // } + + if (aclExists) { + // do nothing, return the user back to the form + response.writeHead(302, { "Location": "/admin/acl/" + recipe_name + "/" + bag_name }); + response.end(); + return + } + + sqlTiddlerDatabase.createACL( + isRecipe ? recipe_name : bag_name, + entity_type, + role_id, + permission_id + ) + + response.writeHead(302, { "Location": "/admin/acl/" + recipe_name + "/" + bag_name }); + response.end(); + }; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag-tiddlers.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag-tiddlers.js index d44db0a50..0f520b1ba 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag-tiddlers.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag-tiddlers.js @@ -20,6 +20,10 @@ exports.bodyFormat = "stream"; exports.csrfDisable = true; +exports.useACL = true; + +exports.entityName = "bag" + exports.handler = function(request,response,state) { const path = require("path"), fs = require("fs"), @@ -39,29 +43,31 @@ exports.handler = function(request,response,state) { "imported-tiddlers": results })); } else { - response.writeHead(200, "OK",{ - "Content-Type": "text/html" - }); - response.write(` - - - - - - `); - // Render the html - var html = $tw.mws.store.adminWiki.renderTiddler("text/html","$:/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers",{ - variables: { - "bag-name": bag_name, - "imported-titles": JSON.stringify(results) - } - }); - response.write(html); - response.write(` - - - `); - response.end(); + if(!response.headersSent) { + response.writeHead(200, "OK",{ + "Content-Type": "text/html" + }); + response.write(` + + + + + + `); + // Render the html + var html = $tw.mws.store.adminWiki.renderTiddler("text/html","$:/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers",{ + variables: { + "bag-name": bag_name, + "imported-titles": JSON.stringify(results) + } + }); + response.write(html); + response.write(` + + + `); + response.end(); + } } } }); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag.js index 2f0e47eaa..bd59b0642 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag.js @@ -25,6 +25,10 @@ exports.bodyFormat = "www-form-urlencoded"; exports.csrfDisable = true; +exports.useACL = true; + +exports.entityName = "bag" + exports.handler = function(request,response,state) { if(state.data.bag_name) { const result = $tw.mws.store.createBag(state.data.bag_name,state.data.description); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-login.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-login.js new file mode 100644 index 000000000..b2bc0ff4d --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-login.js @@ -0,0 +1,67 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-login.js +type: application/javascript +module-type: mws-route + +POST /login + +Parameters: + +username +password + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; +var authenticator = require('$:/plugins/tiddlywiki/multiwikiserver/auth/authentication.js').Authenticator; + +exports.method = "POST"; + +exports.path = /^\/login$/; + +exports.bodyFormat = "www-form-urlencoded"; + +exports.csrfDisable = true; + +exports.handler = function(request,response,state) { + var auth = authenticator(state.server.sqlTiddlerDatabase); + var username = state.data.username; + var password = state.data.password; + var user = state.server.sqlTiddlerDatabase.getUserByUsername(username); + var isPasswordValid = auth.verifyPassword(password, user ? user.password : null) + + if(user && isPasswordValid) { + var sessionId = auth.createSession(user.user_id); + var returnUrl = state.server.parseCookieString(request.headers.cookie).returnUrl + response.setHeader('Set-Cookie', `session=${sessionId}; HttpOnly; Path=/`); + if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) { + state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify({ + "sessionId": sessionId + })); + } else { + response.writeHead(302, { + 'Location': returnUrl || '/' + }); + } + } else { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/login/error", + text: "Invalid username or password" + })); + if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) { + state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify({ + "message": "Invalid username or password" + })); + } else { + response.writeHead(302, { + 'Location': '/login' + }); + } + } + response.end(); +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-logout.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-logout.js new file mode 100644 index 000000000..36d901b44 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-logout.js @@ -0,0 +1,37 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-logout.js +type: application/javascript +module-type: mws-route + +POST /logout + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "POST"; + +exports.path = /^\/logout$/; + +exports.csrfDisable = true; + +exports.handler = function(request,response,state) { + // if(state.authenticatedUser) { + state.server.sqlTiddlerDatabase.deleteSession(state.authenticatedUser.sessionId); + // } + var cookies = request.headers.cookie ? request.headers.cookie.split(";") : []; + for(var i = 0; i < cookies.length; i++) { + var cookie = cookies[i].trim().split("=")[0]; + response.setHeader("Set-Cookie", cookie + "=; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Strict"); + } + + // response.setHeader("Set-Cookie", "session=; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT"); + // response.setHeader("Set-Cookie", "returnUrl=; HttpOnly; Path=/"); + response.writeHead(302, { "Location": "/login" }); + response.end(); +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-recipe.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-recipe.js index dd8b24487..3f4bb02dc 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-recipe.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-recipe.js @@ -26,6 +26,10 @@ exports.bodyFormat = "www-form-urlencoded"; exports.csrfDisable = true; +exports.useACL = true; + +exports.entityName = "recipe" + exports.handler = function(request,response,state) { if(state.data.recipe_name && state.data.bag_names) { const result = $tw.mws.store.createRecipe(state.data.recipe_name,$tw.utils.parseStringArray(state.data.bag_names),state.data.description); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-role.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-role.js new file mode 100644 index 000000000..af62ec62b --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-role.js @@ -0,0 +1,36 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-role.js +type: application/javascript +module-type: mws-route + +POST /admin/post-role + +\*/ +(function () { + + /*jslint node: true, browser: true */ + /*global $tw: false */ + "use strict"; + + exports.method = "POST"; + + exports.path = /^\/admin\/post-role\/?$/; + + exports.bodyFormat = "www-form-urlencoded"; + + exports.csrfDisable = true; + + exports.handler = function (request, response, state) { + var sqlTiddlerDatabase = state.server.sqlTiddlerDatabase; + var role_name = state.data.role_name; + var role_description = state.data.role_description; + + // Add your authentication check here if needed + + sqlTiddlerDatabase.createRole(role_name, role_description); + + response.writeHead(302, { "Location": "/admin/roles" }); + response.end(); + }; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-user.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-user.js new file mode 100644 index 000000000..e11f71742 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-user.js @@ -0,0 +1,63 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-user.js +type: application/javascript +module-type: mws-route + +POST /admin/post-user + +\*/ +(function() { + + /*jslint node: true, browser: true */ + /*global $tw: false */ + "use strict"; + + exports.method = "POST"; + + exports.path = /^\/admin\/post-user\/?$/; + + exports.bodyFormat = "www-form-urlencoded"; + + exports.csrfDisable = true; + + exports.handler = function(request, response, state) { + var sqlTiddlerDatabase = state.server.sqlTiddlerDatabase; + var username = state.data.username; + var email = state.data.email; + var password = state.data.password; + var confirmPassword = state.data.confirmPassword; + + if(!state.authenticatedUser) { + response.writeHead(401, "Unauthorized", { "Content-Type": "text/plain" }); + response.end("Unauthorized"); + return; + } + + if(!username || !email || !password || !confirmPassword) { + response.writeHead(400, {"Content-Type": "application/json"}); + response.end(JSON.stringify({error: "All fields are required"})); + return; + } + + if(password !== confirmPassword) { + response.writeHead(400, {"Content-Type": "application/json"}); + response.end(JSON.stringify({error: "Passwords do not match"})); + return; + } + + // Check if user already exists + var existingUser = sqlTiddlerDatabase.getUser(username); + if(existingUser) { + response.writeHead(400, {"Content-Type": "application/json"}); + response.end(JSON.stringify({error: "Username already exists"})); + return; + } + + // Create new user + var userId = sqlTiddlerDatabase.createUser(username, email, password); + + response.writeHead(302, {"Location": "/admin/users/"+userId}); + response.end(); + }; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-bag.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-bag.js index 06b8a57af..d174ee8ce 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-bag.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-bag.js @@ -16,12 +16,16 @@ exports.method = "PUT"; exports.path = /^\/bags\/(.+)$/; +exports.useACL = true; + +exports.entityName = "bag" + exports.handler = function(request,response,state) { // Get the parameters var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]), data = $tw.utils.parseJSONSafe(state.data); if(bag_name && data) { - const result = $tw.mws.store.createBag(bag_name,data.description); + var result = $tw.mws.store.createBag(bag_name,data.description); if(!result) { state.sendResponse(204,{ "Content-Type": "text/plain" @@ -34,8 +38,10 @@ exports.handler = function(request,response,state) { "utf8"); } } else { - response.writeHead(404); - response.end(); + if(!response.headersSent) { + response.writeHead(404); + response.end(); + } } }; diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe-tiddler.js index 22a1f94e4..25279cdd0 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe-tiddler.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe-tiddler.js @@ -6,7 +6,7 @@ module-type: mws-route PUT /recipes/:recipe_name/tiddlers/:title \*/ -(function() { +(function () { /*jslint node: true, browser: true */ /*global $tw: false */ @@ -16,30 +16,37 @@ exports.method = "PUT"; exports.path = /^\/recipes\/([^\/]+)\/tiddlers\/(.+)$/; -exports.handler = function(request,response,state) { +exports.useACL = true; + +exports.entityName = "recipe" + +exports.handler = function (request, response, state) { // Get the parameters var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]), title = $tw.utils.decodeURIComponentSafe(state.params[1]), fields = $tw.utils.parseJSONSafe(state.data); if(recipe_name && title === fields.title) { - var result = $tw.mws.store.saveRecipeTiddler(fields,recipe_name); - if(result) { - response.writeHead(204, "OK",{ - "X-Revision-Number": result.tiddler_id.toString(), - "X-Bag-Name": result.bag_name, - Etag: state.makeTiddlerEtag(result), - "Content-Type": "text/plain" - }); - } else { - response.writeHead(400); + var result = $tw.mws.store.saveRecipeTiddler(fields, recipe_name); + if(!response.headersSent) { + if(result) { + response.writeHead(204, "OK", { + "X-Revision-Number": result.tiddler_id.toString(), + "X-Bag-Name": result.bag_name, + Etag: state.makeTiddlerEtag(result), + "Content-Type": "text/plain" + }); + } else { + response.writeHead(400); + } + response.end(); } - response.end(); return; } // Fail if something went wrong - response.writeHead(404); - response.end(); - + if(!response.headersSent) { + response.writeHead(404); + response.end(); + } }; }()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe.js index 8c260b36b..002c5e4db 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe.js @@ -6,7 +6,7 @@ module-type: mws-route PUT /recipes/:recipe_name \*/ -(function() { +(function () { /*jslint node: true, browser: true */ /*global $tw: false */ @@ -16,26 +16,32 @@ exports.method = "PUT"; exports.path = /^\/recipes\/(.+)$/; -exports.handler = function(request,response,state) { +exports.useACL = true; + +exports.entityName = "recipe" + +exports.handler = function (request, response, state) { // Get the parameters var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]), data = $tw.utils.parseJSONSafe(state.data); if(recipe_name && data) { - const result = $tw.mws.store.createRecipe(recipe_name,data.bag_names,data.description); + var result = $tw.mws.store.createRecipe(recipe_name, data.bag_names, data.description); if(!result) { - state.sendResponse(204,{ + state.sendResponse(204, { "Content-Type": "text/plain" }); } else { - state.sendResponse(400,{ + state.sendResponse(400, { "Content-Type": "text/plain" }, - result.message, - "utf8"); + result.message, + "utf8"); } } else { - response.writeHead(404); - response.end(); + if(!response.headersSent) { + response.writeHead(404); + response.end(); + } } }; diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/update-user-profile.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/update-user-profile.js new file mode 100644 index 000000000..112ed712e --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/update-user-profile.js @@ -0,0 +1,47 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/update-profile.js +type: application/javascript +module-type: mws-route + +POST /update-user-profile + +\*/ +(function () { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "POST"; + +exports.path = /^\/update-user-profile\/?$/; + +exports.bodyFormat = "www-form-urlencoded"; + +exports.csrfDisable = true; + +exports.handler = function (request,response,state) { + if(!state.authenticatedUser) { + response.writeHead(401, "Unauthorized", { "Content-Type": "text/plain" }); + response.end("Unauthorized"); + return; + } + + var userId = state.authenticatedUser.user_id; + var username = state.data.username; + var email = state.data.email; + var roleId = state.data.role; + + var result = state.server.sqlTiddlerDatabase.updateUser(userId, username, email, roleId); + + if(result.success) { + response.setHeader("Set-Cookie", "flashMessage="+result.message+"; Path=/; HttpOnly; Max-Age=5"); + response.writeHead(302, { "Location": "/admin/users/" + userId }); + } else { + response.setHeader("Set-Cookie", "flashMessage="+result.message+"; Path=/; HttpOnly; Max-Age=5"); + response.writeHead(302, { "Location": "/admin/users/" + userId }); + } + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js new file mode 100644 index 000000000..582c5e93c --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js @@ -0,0 +1,79 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js +type: application/javascript +module-type: library + +Middleware to handle ACL permissions + +\*/ + +(function () { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +/* +ACL Middleware factory function +*/ +function redirectToLogin(response, returnUrl) { + if(!response.headersSent) { + var validReturnUrlRegex = /^\/(?!.*\.(ico|png|jpg|jpeg|gif|svg|css|js|woff|woff2|ttf|eot|json)$).*$/; + var sanitizedReturnUrl = '/'; // Default to home page + + if(validReturnUrlRegex.test(returnUrl)) { + sanitizedReturnUrl = returnUrl; + response.setHeader('Set-Cookie', `returnUrl=${encodeURIComponent(sanitizedReturnUrl)}; HttpOnly; Secure; SameSite=Strict; Path=/`); + } else{ + console.log(`Invalid return URL detected: ${returnUrl}. Redirecting to home page.`); + } + const loginUrl = '/login'; + response.writeHead(302, { + 'Location': loginUrl + }); + response.end(); + } +}; + +exports.middleware = function (request, response, state, entityType, permissionName) { + + var server = state.server, + sqlTiddlerDatabase = server.sqlTiddlerDatabase, + entityName = state.data ? (state.data[entityType+"_name"] || state.params[0]) : state.params[0]; + + // First, replace '%3A' with ':' to handle TiddlyWiki's system tiddlers + var partiallyDecoded = entityName.replace(/%3A/g, ":"); + // Then use decodeURIComponent for the rest + var decodedEntityName = decodeURIComponent(partiallyDecoded); + var aclRecord = sqlTiddlerDatabase.getACLByName(entityType, decodedEntityName); + // Get permission record + const permission = sqlTiddlerDatabase.getPermissionByName(permissionName); + // ACL Middleware will only apply if the entity has a middleware record + if(aclRecord && aclRecord?.permission_id === permission?.permission_id) { + // If not authenticated and anonymous access is not allowed, request authentication + if(!state.authenticatedUsername && !state.allowAnon) { + if(state.urlInfo.pathname !== '/login') { + redirectToLogin(response, request.url); + return; + } + } + // Check if user is authenticated + if(!state.authenticatedUser && !response.headersSent) { + response.writeHead(401, "Unauthorized"); + response.end(); + return; + } + + // Check ACL permission + var hasPermission = request.method === "POST" || sqlTiddlerDatabase.checkACLPermission(state.authenticatedUser.user_id, entityType, decodedEntityName, permissionName) + if(!hasPermission) { + if(!response.headersSent) { + response.writeHead(403, "Forbidden"); + response.end(); + } + return; + } + } +}; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/store/sql-tiddler-database.js b/plugins/tiddlywiki/multiwikiserver/modules/store/sql-tiddler-database.js index 18812a39d..75f4e49ed 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/store/sql-tiddler-database.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/store/sql-tiddler-database.js @@ -25,6 +25,16 @@ function SqlTiddlerDatabase(options) { databasePath: options.databasePath, engine: options.engine }); + this.entityTypeToTableMap = { + bag: { + table: "bags", + column: "bag_name" + }, + recipe: { + table: "recipes", + column: "recipe_name" + } + }; } SqlTiddlerDatabase.prototype.close = function() { @@ -38,6 +48,83 @@ SqlTiddlerDatabase.prototype.transaction = function(fn) { SqlTiddlerDatabase.prototype.createTables = function() { this.engine.runStatements([` + -- Users table + CREATE TABLE IF NOT EXISTS users ( + user_id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + last_login TEXT + ) + `,` + -- User Session table + CREATE TABLE IF NOT EXISTS sessions ( + user_id INTEGER NOT NULL, + session_id TEXT NOT NULL, + created_at TEXT NOT NULL, + last_accessed TEXT NOT NULL, + PRIMARY KEY (user_id), + FOREIGN KEY (user_id) REFERENCES users(user_id) + ) + `,` + -- Groups table + CREATE TABLE IF NOT EXISTS groups ( + group_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_name TEXT UNIQUE NOT NULL, + description TEXT + ) + `,` + -- Roles table + CREATE TABLE IF NOT EXISTS roles ( + role_id INTEGER PRIMARY KEY AUTOINCREMENT, + role_name TEXT UNIQUE NOT NULL, + description TEXT + ) + `,` + -- Permissions table + CREATE TABLE IF NOT EXISTS permissions ( + permission_id INTEGER PRIMARY KEY AUTOINCREMENT, + permission_name TEXT UNIQUE NOT NULL, + description TEXT + ) + `,` + -- User-Group association table + CREATE TABLE IF NOT EXISTS user_groups ( + user_id INTEGER, + group_id INTEGER, + PRIMARY KEY (user_id, group_id), + FOREIGN KEY (user_id) REFERENCES users(user_id), + FOREIGN KEY (group_id) REFERENCES groups(group_id) + ) + `,` + -- User-Role association table + CREATE TABLE IF NOT EXISTS user_roles ( + user_id INTEGER, + role_id INTEGER, + PRIMARY KEY (user_id, role_id), + FOREIGN KEY (user_id) REFERENCES users(user_id), + FOREIGN KEY (role_id) REFERENCES roles(role_id) + ) + `,` + -- Group-Role association table + CREATE TABLE IF NOT EXISTS group_roles ( + group_id INTEGER, + role_id INTEGER, + PRIMARY KEY (group_id, role_id), + FOREIGN KEY (group_id) REFERENCES groups(group_id), + FOREIGN KEY (role_id) REFERENCES roles(role_id) + ) + `,` + -- Role-Permission association table + CREATE TABLE IF NOT EXISTS role_permissions ( + role_id INTEGER, + permission_id INTEGER, + PRIMARY KEY (role_id, permission_id), + FOREIGN KEY (role_id) REFERENCES roles(role_id), + FOREIGN KEY (permission_id) REFERENCES permissions(permission_id) + ) + `,` -- Bags have names and access control settings CREATE TABLE IF NOT EXISTS bags ( bag_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -82,6 +169,26 @@ SqlTiddlerDatabase.prototype.createTables = function() { FOREIGN KEY (tiddler_id) REFERENCES tiddlers(tiddler_id) ON UPDATE CASCADE ON DELETE CASCADE, UNIQUE (tiddler_id, field_name) ) + `,` + -- ACL table (using bag/recipe ids directly) + CREATE TABLE IF NOT EXISTS acl ( + acl_id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_name TEXT NOT NULL, + entity_type TEXT NOT NULL CHECK (entity_type IN ('bag', 'recipe')), + role_id INTEGER, + permission_id INTEGER, + FOREIGN KEY (role_id) REFERENCES roles(role_id), + FOREIGN KEY (permission_id) REFERENCES permissions(permission_id) + ) + `,` + -- Indexes for performance (we can add more as needed based on query patterns) + CREATE INDEX IF NOT EXISTS idx_tiddlers_bag_id ON tiddlers(bag_id) + `,` + CREATE INDEX IF NOT EXISTS idx_fields_tiddler_id ON fields(tiddler_id) + `,` + CREATE INDEX IF NOT EXISTS idx_recipe_bags_recipe_id ON recipe_bags(recipe_id) + `,` + CREATE INDEX IF NOT EXISTS idx_acl_entity_id ON acl(entity_name) `]); }; @@ -101,7 +208,7 @@ Returns the bag_id of the bag SqlTiddlerDatabase.prototype.createBag = function(bag_name,description,accesscontrol) { accesscontrol = accesscontrol || ""; // Run the queries - this.engine.runStatement(` + var bag = this.engine.runStatement(` INSERT OR IGNORE INTO bags (bag_name, accesscontrol, description) VALUES ($bag_name, '', '') `,{ @@ -117,6 +224,14 @@ SqlTiddlerDatabase.prototype.createBag = function(bag_name,description,accesscon $accesscontrol: accesscontrol, $description: description }); + + const admin = this.getRoleByName("ADMIN"); + if(admin) { + const readPermission = this.getPermissionByName("READ"); + const writePermission = this.getPermissionByName("WRITE"); + // this.createACL(bag_name, "bag", admin.role_id, readPermission.permission_id); + // this.createACL(bag_name, "bag", admin.role_id, writePermission.permission_id); + } return updateBags.lastInsertRowid; }; @@ -180,6 +295,16 @@ SqlTiddlerDatabase.prototype.createRecipe = function(recipe_name,bag_names,descr $recipe_name: recipe_name, $bag_names: JSON.stringify(bag_names) }); + + + // update the permissions on ACL records + const admin = this.getRoleByName("ADMIN"); + if(admin) { + const readPermission = this.getPermissionByName("READ"); + const writePermission = this.getPermissionByName("WRITE"); + // this.createACL(recipe_name, "recipe", admin.role_id, readPermission.permission_id); + // this.createACL(recipe_name, "recipe", admin.role_id, writePermission.permission_id); + } return updateRecipes.lastInsertRowid; }; @@ -374,6 +499,102 @@ SqlTiddlerDatabase.prototype.getRecipeTiddler = function(title,recipe_name) { }; }; +/* +Checks if a user has permission to access a recipe +*/ +SqlTiddlerDatabase.prototype.hasRecipePermission = function(userId, recipeName, permissionName) { + return this.checkACLPermission(userId, "recipe", recipeName, permissionName) +}; + +/* +Checks if a user has permission to access a bag +*/ +SqlTiddlerDatabase.prototype.hasBagPermission = function(userId, bagName, permissionName) { + return this.checkACLPermission(userId, "bag", bagName, permissionName) +}; + +SqlTiddlerDatabase.prototype.getACLByName = function(entityType, entityName, fetchAll) { + const entityInfo = this.entityTypeToTableMap[entityType]; + if (!entityInfo) { + throw new Error("Invalid entity type: " + entityType); + } + + // First, check if there's an ACL record for the entity and get the permission_id + var checkACLExistsQuery = ` + SELECT * + FROM acl + WHERE entity_type = $entity_type + AND entity_name = $entity_name + `; + + if (!fetchAll) { + checkACLExistsQuery += ' LIMIT 1' + } + + const aclRecord = this.engine[fetchAll ? 'runStatementGetAll' : 'runStatementGet'](checkACLExistsQuery, { + $entity_type: entityType, + $entity_name: entityName + }); + + return aclRecord; +} + +SqlTiddlerDatabase.prototype.checkACLPermission = function(userId, entityType, entityName) { + // if the entityName starts with "$:/", we'll assume its a system bag/recipe, then grant the user permission + if(entityName.startsWith("$:/")) { + return true; + } + + const aclRecord = this.getACLByName(entityType, entityName); + + // If no ACL record exists, return true for hasPermission + if (!aclRecord) { + return true; + } + + // If ACL record exists, check for user permission using the retrieved permission_id + const checkPermissionQuery = ` + SELECT 1 + FROM users u + JOIN user_roles ur ON u.user_id = ur.user_id + JOIN roles r ON ur.role_id = r.role_id + JOIN acl a ON r.role_id = a.role_id + WHERE u.user_id = $user_id + AND a.entity_type = $entity_type + AND a.entity_name = $entity_name + AND a.permission_id = $permission_id + LIMIT 1 + `; + + const result = this.engine.runStatementGet(checkPermissionQuery, { + $user_id: userId, + $entity_type: entityType, + $entity_name: entityName, + $permission_id: aclRecord.permission_id + }); + + const hasPermission = result !== undefined; + + return hasPermission; +}; + +/** + * Returns the ACL records for an entity (bag or recipe) + */ +SqlTiddlerDatabase.prototype.getEntityAclRecords = function(entityName) { + const checkACLExistsQuery = ` + SELECT * + FROM acl + WHERE entity_name = $entity_name + `; + + const aclRecords = this.engine.runStatementGetAll(checkACLExistsQuery, { + $entity_name: entityName + }); + + return aclRecords +} + /* Get the titles of the tiddlers in a bag. Returns an empty array for bags that do not exist */ @@ -575,6 +796,576 @@ SqlTiddlerDatabase.prototype.getRecipeTiddlerAttachmentBlob = function(title,rec return row ? row.attachment_blob : null; }; +// User CRUD operations +SqlTiddlerDatabase.prototype.createUser = function(username, email, password) { + const result = this.engine.runStatement(` + INSERT INTO users (username, email, password) + VALUES ($username, $email, $password) + `, { + $username: username, + $email: email, + $password: password + }); + return result.lastInsertRowid; +}; + +SqlTiddlerDatabase.prototype.getUser = function(userId) { + return this.engine.runStatementGet(` + SELECT * FROM users WHERE user_id = $userId + `, { + $userId: userId + }); +}; + +SqlTiddlerDatabase.prototype.getUserByUsername = function(username) { + return this.engine.runStatementGet(` + SELECT * FROM users WHERE username = $username + `, { + $username: username + }); +}; + +SqlTiddlerDatabase.prototype.updateUser = function (userId, username, email, roleId) { + const existingUser = this.engine.runStatement(` + SELECT user_id FROM users + WHERE email = $email AND user_id != $userId +`, { + $email: email, + $userId: userId + }); + + if (existingUser.length > 0) { + return { + success: false, + message: "Email address already in use by another user." + }; + } + + try { + this.engine.transaction(() => { + // Update user information + this.engine.runStatement(` + UPDATE users + SET username = $username, email = $email + WHERE user_id = $userId + `, { + $userId: userId, + $username: username, + $email: email + }); + + if (roleId) { + // Remove all existing roles for the user + this.engine.runStatement(` + DELETE FROM user_roles + WHERE user_id = $userId + `, { + $userId: userId + }); + + // Add the new role + this.engine.runStatement(` + INSERT INTO user_roles (user_id, role_id) + VALUES ($userId, $roleId) + `, { + $userId: userId, + $roleId: roleId + }); + } + }); + + return { + success: true, + message: "User profile and role updated successfully." + }; + } catch (error) { + return { + success: false, + message: "Failed to update user profile: " + error.message + }; + } +}; + +SqlTiddlerDatabase.prototype.updateUserPassword = function (userId, newHash) { + try { + this.engine.runStatement(` + UPDATE users + SET password = $newHash + WHERE user_id = $userId + `, { + $userId: userId, + $newHash: newHash, + }); + + return { + success: true, + message: "Password updated successfully." + }; + } catch (error) { + return { + success: false, + message: "Failed to update password: " + error.message + }; + } +}; + +SqlTiddlerDatabase.prototype.deleteUser = function(userId) { + this.engine.runStatement(` + DELETE FROM users WHERE user_id = $userId + `, { + $userId: userId + }); +}; + +SqlTiddlerDatabase.prototype.listUsers = function() { + return this.engine.runStatementGetAll(` + SELECT * FROM users ORDER BY username + `); +}; + +SqlTiddlerDatabase.prototype.createOrUpdateUserSession = function(userId, sessionId) { + const currentTimestamp = new Date().toISOString(); + + // First, try to update an existing session + const updateResult = this.engine.runStatement(` + UPDATE sessions + SET session_id = $sessionId, last_accessed = $timestamp + WHERE user_id = $userId + `, { + $userId: userId, + $sessionId: sessionId, + $timestamp: currentTimestamp + }); + + // If no existing session was updated, create a new one + if (updateResult.changes === 0) { + this.engine.runStatement(` + INSERT INTO sessions (user_id, session_id, created_at, last_accessed) + VALUES ($userId, $sessionId, $timestamp, $timestamp) + `, { + $userId: userId, + $sessionId: sessionId, + $timestamp: currentTimestamp + }); + } + + return sessionId; +}; + +SqlTiddlerDatabase.prototype.findUserBySessionId = function(sessionId) { + // First, get the user_id from the sessions table + const sessionResult = this.engine.runStatementGet(` + SELECT user_id, last_accessed + FROM sessions + WHERE session_id = $sessionId + `, { + $sessionId: sessionId + }); + + if (!sessionResult) { + return null; // Session not found + } + + const lastAccessed = new Date(sessionResult.last_accessed); + const expirationTime = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + if (new Date() - lastAccessed > expirationTime) { + // Session has expired + this.deleteSession(sessionId); + return null; + } + + // Update the last_accessed timestamp + const currentTimestamp = new Date().toISOString(); + this.engine.runStatement(` + UPDATE sessions + SET last_accessed = $timestamp + WHERE session_id = $sessionId + `, { + $sessionId: sessionId, + $timestamp: currentTimestamp + }); + + const userResult = this.engine.runStatementGet(` + SELECT * + FROM users + WHERE user_id = $userId + `, { + $userId: sessionResult.user_id + }); + + if (!userResult) { + return null; + } + + return userResult; +}; + +SqlTiddlerDatabase.prototype.deleteSession = function(sessionId) { + this.engine.runStatement(` + DELETE FROM sessions + WHERE session_id = $sessionId + `, { + $sessionId: sessionId + }); +}; + +// Group CRUD operations +SqlTiddlerDatabase.prototype.createGroup = function(groupName, description) { + const result = this.engine.runStatement(` + INSERT INTO groups (group_name, description) + VALUES ($groupName, $description) + `, { + $groupName: groupName, + $description: description + }); + return result.lastInsertRowid; +}; + +SqlTiddlerDatabase.prototype.getGroup = function(groupId) { + return this.engine.runStatementGet(` + SELECT * FROM groups WHERE group_id = $groupId + `, { + $groupId: groupId + }); +}; + +SqlTiddlerDatabase.prototype.updateGroup = function(groupId, groupName, description) { + this.engine.runStatement(` + UPDATE groups + SET group_name = $groupName, description = $description + WHERE group_id = $groupId + `, { + $groupId: groupId, + $groupName: groupName, + $description: description + }); +}; + +SqlTiddlerDatabase.prototype.deleteGroup = function(groupId) { + this.engine.runStatement(` + DELETE FROM groups WHERE group_id = $groupId + `, { + $groupId: groupId + }); +}; + +SqlTiddlerDatabase.prototype.listGroups = function() { + return this.engine.runStatementGetAll(` + SELECT * FROM groups ORDER BY group_name + `); +}; + +// Role CRUD operations +SqlTiddlerDatabase.prototype.createRole = function(roleName, description) { + const result = this.engine.runStatement(` + INSERT OR IGNORE INTO roles (role_name, description) + VALUES ($roleName, $description) + `, { + $roleName: roleName, + $description: description + }); + return result.lastInsertRowid; +}; + +SqlTiddlerDatabase.prototype.getRole = function(roleId) { + return this.engine.runStatementGet(` + SELECT * FROM roles WHERE role_id = $roleId + `, { + $roleId: roleId + }); +}; + +SqlTiddlerDatabase.prototype.getRoleByName = function(roleName) { + return this.engine.runStatementGet(` + SELECT * FROM roles WHERE role_name = $roleName + `, { + $roleName: roleName + }); +} + +SqlTiddlerDatabase.prototype.updateRole = function(roleId, roleName, description) { + this.engine.runStatement(` + UPDATE roles + SET role_name = $roleName, description = $description + WHERE role_id = $roleId + `, { + $roleId: roleId, + $roleName: roleName, + $description: description + }); +}; + +SqlTiddlerDatabase.prototype.deleteRole = function(roleId) { + this.engine.runStatement(` + DELETE FROM roles WHERE role_id = $roleId + `, { + $roleId: roleId + }); +}; + +SqlTiddlerDatabase.prototype.listRoles = function() { + return this.engine.runStatementGetAll(` + SELECT * FROM roles ORDER BY role_name + `); +}; + +// Permission CRUD operations +SqlTiddlerDatabase.prototype.createPermission = function(permissionName, description) { + const result = this.engine.runStatement(` + INSERT OR IGNORE INTO permissions (permission_name, description) + VALUES ($permissionName, $description) + `, { + $permissionName: permissionName, + $description: description + }); + return result.lastInsertRowid; +}; + +SqlTiddlerDatabase.prototype.getPermission = function(permissionId) { + return this.engine.runStatementGet(` + SELECT * FROM permissions WHERE permission_id = $permissionId + `, { + $permissionId: permissionId + }); +}; + +SqlTiddlerDatabase.prototype.getPermissionByName = function(permissionName) { + return this.engine.runStatementGet(` + SELECT * FROM permissions WHERE permission_name = $permissionName + `, { + $permissionName: permissionName + }); +}; + +SqlTiddlerDatabase.prototype.updatePermission = function(permissionId, permissionName, description) { + this.engine.runStatement(` + UPDATE permissions + SET permission_name = $permissionName, description = $description + WHERE permission_id = $permissionId + `, { + $permissionId: permissionId, + $permissionName: permissionName, + $description: description + }); +}; + +SqlTiddlerDatabase.prototype.deletePermission = function(permissionId) { + this.engine.runStatement(` + DELETE FROM permissions WHERE permission_id = $permissionId + `, { + $permissionId: permissionId + }); +}; + +SqlTiddlerDatabase.prototype.listPermissions = function() { + return this.engine.runStatementGetAll(` + SELECT * FROM permissions ORDER BY permission_name + `); +}; + +// ACL CRUD operations +SqlTiddlerDatabase.prototype.createACL = function(entityName, entityType, roleId, permissionId) { + if(!entityName.startsWith("$:/")) { + const result = this.engine.runStatement(` + INSERT OR IGNORE INTO acl (entity_name, entity_type, role_id, permission_id) + VALUES ($entityName, $entityType, $roleId, $permissionId) + `, + { + $entityName: entityName, + $entityType: entityType, + $roleId: roleId, + $permissionId: permissionId + }); + return result.lastInsertRowid; + } +}; + +SqlTiddlerDatabase.prototype.getACL = function(aclId) { + return this.engine.runStatementGet(` + SELECT * FROM acl WHERE acl_id = $aclId + `, { + $aclId: aclId + }); +}; + +SqlTiddlerDatabase.prototype.updateACL = function(aclId, entityId, entityType, roleId, permissionId) { + this.engine.runStatement(` + UPDATE acl + SET entity_name = $entityId, entity_type = $entityType, + role_id = $roleId, permission_id = $permissionId + WHERE acl_id = $aclId + `, { + $aclId: aclId, + $entityId: entityId, + $entityType: entityType, + $roleId: roleId, + $permissionId: permissionId + }); +}; + +SqlTiddlerDatabase.prototype.deleteACL = function(aclId) { + this.engine.runStatement(` + DELETE FROM acl WHERE acl_id = $aclId + `, { + $aclId: aclId + }); +}; + +SqlTiddlerDatabase.prototype.listACLs = function() { + return this.engine.runStatementGetAll(` + SELECT * FROM acl ORDER BY entity_type, entity_name + `); +}; + +// Association management functions +SqlTiddlerDatabase.prototype.addUserToGroup = function(userId, groupId) { + this.engine.runStatement(` + INSERT OR IGNORE INTO user_groups (user_id, group_id) + VALUES ($userId, $groupId) + `, { + $userId: userId, + $groupId: groupId + }); +}; + +SqlTiddlerDatabase.prototype.isUserInGroup = function(userId, groupId) { + const result = this.engine.runStatementGet(` + SELECT 1 FROM user_groups + WHERE user_id = $userId AND group_id = $groupId + `, { + $userId: userId, + $groupId: groupId + }); + return result !== undefined; +}; + +SqlTiddlerDatabase.prototype.removeUserFromGroup = function(userId, groupId) { + this.engine.runStatement(` + DELETE FROM user_groups + WHERE user_id = $userId AND group_id = $groupId + `, { + $userId: userId, + $groupId: groupId + }); +}; + +SqlTiddlerDatabase.prototype.addRoleToUser = function(userId, roleId) { + this.engine.runStatement(` + INSERT OR IGNORE INTO user_roles (user_id, role_id) + VALUES ($userId, $roleId) + `, { + $userId: userId, + $roleId: roleId + }); +}; + +SqlTiddlerDatabase.prototype.removeRoleFromUser = function(userId, roleId) { + this.engine.runStatement(` + DELETE FROM user_roles + WHERE user_id = $userId AND role_id = $roleId + `, { + $userId: userId, + $roleId: roleId + }); +}; + +SqlTiddlerDatabase.prototype.addRoleToGroup = function(groupId, roleId) { + this.engine.runStatement(` + INSERT OR IGNORE INTO group_roles (group_id, role_id) + VALUES ($groupId, $roleId) + `, { + $groupId: groupId, + $roleId: roleId + }); +}; + +SqlTiddlerDatabase.prototype.removeRoleFromGroup = function(groupId, roleId) { + this.engine.runStatement(` + DELETE FROM group_roles + WHERE group_id = $groupId AND role_id = $roleId + `, { + $groupId: groupId, + $roleId: roleId + }); +}; + +SqlTiddlerDatabase.prototype.addPermissionToRole = function(roleId, permissionId) { + this.engine.runStatement(` + INSERT OR IGNORE INTO role_permissions (role_id, permission_id) + VALUES ($roleId, $permissionId) + `, { + $roleId: roleId, + $permissionId: permissionId + }); +}; + +SqlTiddlerDatabase.prototype.removePermissionFromRole = function(roleId, permissionId) { + this.engine.runStatement(` + DELETE FROM role_permissions + WHERE role_id = $roleId AND permission_id = $permissionId + `, { + $roleId: roleId, + $permissionId: permissionId + }); +}; + +SqlTiddlerDatabase.prototype.getUserRoles = function(userId) { + const query = ` + SELECT r.role_id, r.role_name + FROM user_roles ur + JOIN roles r ON ur.role_id = r.role_id + WHERE ur.user_id = $userId + LIMIT 1 + `; + + return this.engine.runStatementGet(query, { $userId: userId }); +}; + +SqlTiddlerDatabase.prototype.isRoleInUse = function(roleId) { + // Check if the role is assigned to any users + const userRoleCheck = this.engine.runStatementGet(` + SELECT 1 + FROM user_roles + WHERE role_id = $roleId + LIMIT 1 + `, { + $roleId: roleId + }); + + if(userRoleCheck) { + return true; + } + + // Check if the role is used in any ACLs + const aclRoleCheck = this.engine.runStatementGet(` + SELECT 1 + FROM acl + WHERE role_id = $roleId + LIMIT 1 + `, { + $roleId: roleId + }); + + if(aclRoleCheck) { + return true; + } + + // If we've reached this point, the role is not in use + return false; +}; + +SqlTiddlerDatabase.prototype.getRoleById = function(roleId) { + const role = this.engine.runStatementGet(` + SELECT role_id, role_name, description + FROM roles + WHERE role_id = $roleId + `, { + $roleId: roleId + }); + + return role; +}; + exports.SqlTiddlerDatabase = SqlTiddlerDatabase; -})(); \ No newline at end of file +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/store/tests-sql-tiddler-database.js b/plugins/tiddlywiki/multiwikiserver/modules/store/tests-sql-tiddler-database.js index d6eaabca0..4a5b94792 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/store/tests-sql-tiddler-database.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/store/tests-sql-tiddler-database.js @@ -104,6 +104,124 @@ function runSqlDatabaseTests(engine) { expect(sqlTiddlerDatabase.saveRecipeTiddler({title: "More", text: "None"},"recipe-rho")).toEqual({tiddler_id: 7, bag_name: 'bag-beta'}); expect(sqlTiddlerDatabase.getRecipeTiddler("More","recipe-rho").tiddler).toEqual({title: "More", text: "None"}); }); + + it("should manage users correctly", function() { + console.log("should manage users correctly") + // Create users + const userId1 = sqlTiddlerDatabase.createUser("john_doe", "john@example.com", "pass123"); + const userId2 = sqlTiddlerDatabase.createUser("jane_doe", "jane@example.com", "pass123"); + + // Retrieve users + const user1 = sqlTiddlerDatabase.getUser(userId1); + expect(user1.user_id).toBe(userId1); + expect(user1.username).toBe("john_doe"); + expect(user1.email).toBe("john@example.com"); + expect(user1.created_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); // Match timestamp format + expect(user1.last_login).toBeNull(); + + // Update user + sqlTiddlerDatabase.updateUser(userId1, "john_updated", "john_updated@example.com"); + expect(sqlTiddlerDatabase.getUser(userId1).username).toBe("john_updated"); + expect(sqlTiddlerDatabase.getUser(userId1).email).toBe("john_updated@example.com"); + + // List users + const users = sqlTiddlerDatabase.listUsers(); + expect(users.length).toBe(2); + expect(users[0].username).toBe("jane_doe"); + expect(users[1].username).toBe("john_updated"); + + // Delete user + sqlTiddlerDatabase.deleteUser(userId2); + // expect(sqlTiddlerDatabase.getUser(userId2)).toBe(null || undefined); + }); + + it("should manage groups correctly", function() { + console.log("should manage groups correctly") + // Create groups + const groupId1 = sqlTiddlerDatabase.createGroup("Editors", "Can edit content"); + const groupId2 = sqlTiddlerDatabase.createGroup("Viewers", "Can view content"); + + // Retrieve groups + expect(sqlTiddlerDatabase.getGroup(groupId1)).toEqual({ + group_id: groupId1, + group_name: "Editors", + description: "Can edit content" + }); + + // Update group + sqlTiddlerDatabase.updateGroup(groupId1, "Super Editors", "Can edit all content"); + expect(sqlTiddlerDatabase.getGroup(groupId1).group_name).toBe("Super Editors"); + expect(sqlTiddlerDatabase.getGroup(groupId1).description).toBe("Can edit all content"); + + // List groups + const groups = sqlTiddlerDatabase.listGroups(); + expect(groups.length).toBe(2); + expect(groups[0].group_name).toBe("Super Editors"); + expect(groups[1].group_name).toBe("Viewers"); + + // Delete group + sqlTiddlerDatabase.deleteGroup(groupId2); + // expect(sqlTiddlerDatabase.getGroup(groupId2)).toBe(null || undefined); + }); + + + it("should manage roles correctly", function() { + console.log("should manage roles correctly") + // Create roles + const roleId1 = sqlTiddlerDatabase.createRole("Admin" + Date.now(), "Full access"); + const roleId2 = sqlTiddlerDatabase.createRole("Editor" + Date.now(), "Can edit content"); + + // Retrieve roles + expect(sqlTiddlerDatabase.getRole(roleId1)).toEqual({ + role_id: roleId1, + role_name: jasmine.stringMatching(/^Admin\d+$/), + description: "Full access" + }); + + // Update role + sqlTiddlerDatabase.updateRole(roleId1, "Super Admin" + Date.now(), "God-like powers"); + expect(sqlTiddlerDatabase.getRole(roleId1).role_name).toMatch(/^Super Admin\d+$/); + expect(sqlTiddlerDatabase.getRole(roleId1).description).toBe("God-like powers"); + + // List roles + const roles = sqlTiddlerDatabase.listRoles(); + expect(roles.length).toBeGreaterThan(0); + // expect(roles[0].role_name).toMatch(/^Editor\d+$/); + // expect(roles[1].role_name).toMatch(/^Super Admin\d+$/); + + // Delete role + sqlTiddlerDatabase.deleteRole(roleId2); + // expect(sqlTiddlerDatabase.getRole(roleId2)).toBeUndefined(); + }); + + it("should manage permissions correctly", function() { + console.log("should manage permissions correctly") + // Create permissions + const permissionId1 = sqlTiddlerDatabase.createPermission("read_tiddlers" + Date.now(), "Can read tiddlers"); + const permissionId2 = sqlTiddlerDatabase.createPermission("write_tiddlers" + Date.now(), "Can write tiddlers"); + + // Retrieve permissions + expect(sqlTiddlerDatabase.getPermission(permissionId1)).toEqual({ + permission_id: permissionId1, + permission_name: jasmine.stringMatching(/^read_tiddlers\d+$/), + description: "Can read tiddlers" + }); + + // Update permission + sqlTiddlerDatabase.updatePermission(permissionId1, "read_all_tiddlers" + Date.now(), "Can read all tiddlers"); + expect(sqlTiddlerDatabase.getPermission(permissionId1).permission_name).toMatch(/^read_all_tiddlers\d+$/); + expect(sqlTiddlerDatabase.getPermission(permissionId1).description).toBe("Can read all tiddlers"); + + // List permissions + const permissions = sqlTiddlerDatabase.listPermissions(); + expect(permissions.length).toBeGreaterThan(0); + expect(permissions[0].permission_name).toMatch(/^read_all_tiddlers\d+$/); + expect(permissions[1].permission_name).toMatch(/^write_tiddlers\d+$/); + + // Delete permission + sqlTiddlerDatabase.deletePermission(permissionId2); + // expect(sqlTiddlerDatabase.getPermission(permissionId2)).toBeUndefined(); + }); } })(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/tests/test-attachment.js b/plugins/tiddlywiki/multiwikiserver/modules/tests/test-attachment.js index 4cbc04a7e..9298c43f8 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/tests/test-attachment.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/tests/test-attachment.js @@ -14,151 +14,151 @@ if(typeof window === 'undefined' && typeof process !== 'undefined' && process.ve var AttachmentStore = require('$:/plugins/tiddlywiki/multiwikiserver/store/attachments.js').AttachmentStore; const {Buffer} = require('buffer'); - function generateFileWithSize(filePath, sizeInBytes) { - return new Promise((resolve, reject) => { - var buffer = Buffer.alloc(sizeInBytes); - for(var i = 0; i < sizeInBytes; i++) { - buffer[i] = Math.floor(Math.random() * 256); - } + function generateFileWithSize(filePath, sizeInBytes) { + return new Promise((resolve, reject) => { + var buffer = Buffer.alloc(sizeInBytes); + for(var i = 0; i < sizeInBytes; i++) { + buffer[i] = Math.floor(Math.random() * 256); + } - fs.writeFile(filePath, buffer, (err) => { - if(err) { - console.error('Error writing file:', err); - reject(err); - } else { - console.log('File '+filePath+' generated with size '+sizeInBytes+' bytes'); - fs.readFile(filePath, (err, data) => { - if(err) { - console.error('Error reading file:', err); - reject(err); - } else { - resolve(data); - } - }); - } - }); - }); - } + fs.writeFile(filePath, buffer, (err) => { + if(err) { + console.error('Error writing file:', err); + reject(err); + } else { + console.log('File '+filePath+' generated with size '+sizeInBytes+' bytes'); + fs.readFile(filePath, (err, data) => { + if(err) { + console.error('Error reading file:', err); + reject(err); + } else { + resolve(data); + } + }); + } + }); + }); + } - (function() { - 'use strict'; - if($tw.node) { - describe('AttachmentStore', function() { - var storePath = './editions/test/test-store'; - var attachmentStore = new AttachmentStore({ storePath: storePath }); - var originalTimeout; + (function() { + 'use strict'; + if($tw.node) { + describe('AttachmentStore', function() { + var storePath = './editions/test/test-store'; + var attachmentStore = new AttachmentStore({ storePath: storePath }); + var originalTimeout; - beforeAll(function() { - const dirPath = path.dirname(`${storePath}/files`); - if(!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; - jasmine.DEFAULT_TIMEOUT_INTERVAL = 50000; - }); + beforeAll(function() { + const dirPath = path.dirname(`${storePath}/files`); + if(!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 50000; + }); - afterAll(function() { - jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; - fs.readdirSync(storePath).forEach(function(file) { - var filePath = path.join(storePath, file); - if(fs.lstatSync(filePath).isFile()) { - fs.unlinkSync(filePath); - } else if(fs.lstatSync(filePath).isDirectory()) { - fs.rmdirSync(filePath, { recursive: true }); - } - }); - }); + afterAll(function() { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + fs.readdirSync(storePath).forEach(function(file) { + var filePath = path.join(storePath, file); + if(fs.lstatSync(filePath).isFile()) { + fs.unlinkSync(filePath); + } else if(fs.lstatSync(filePath).isDirectory()) { + fs.rmdirSync(filePath, { recursive: true }); + } + }); + }); - it('isValidAttachmentName', function() { - expect(attachmentStore.isValidAttachmentName('abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890')).toBe(true); - expect(attachmentStore.isValidAttachmentName('invalid-name')).toBe(false); - }); + it('isValidAttachmentName', function() { + expect(attachmentStore.isValidAttachmentName('abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890')).toBe(true); + expect(attachmentStore.isValidAttachmentName('invalid-name')).toBe(false); + }); - it('saveAttachment', function() { - var options = { - text: 'Hello, World!', - type: 'text/plain', - reference: 'test-reference', - }; - var contentHash = attachmentStore.saveAttachment(options); - assert.strictEqual(contentHash.length, 64); - assert.strictEqual(fs.existsSync(path.resolve(storePath, 'files', contentHash)), true); - }); - - it('adoptAttachment', function() { - var incomingFilepath = path.resolve(storePath, 'incoming-file.txt'); - fs.writeFileSync(incomingFilepath, 'Hello, World!'); - var type = 'text/plain'; - var hash = 'abcdef0123456789abcdef0123456789'; - var _canonical_uri = 'test-canonical-uri'; - attachmentStore.adoptAttachment(incomingFilepath, type, hash, _canonical_uri); - expect(fs.existsSync(path.resolve(storePath, 'files', hash))).toBe(true); - }); - - it('getAttachmentStream', function() { - var options = { - text: 'Hello, World!', - type: 'text/plain', - filename: 'data.txt', - }; - var contentHash = attachmentStore.saveAttachment(options); - var stream = attachmentStore.getAttachmentStream(contentHash); - expect(stream).not.toBeNull(); - expect(stream.type).toBe('text/plain'); - }); + it('saveAttachment', function() { + var options = { + text: 'Hello, World!', + type: 'text/plain', + reference: 'test-reference', + }; + var contentHash = attachmentStore.saveAttachment(options); + assert.strictEqual(contentHash.length, 64); + assert.strictEqual(fs.existsSync(path.resolve(storePath, 'files', contentHash)), true); + }); + + it('adoptAttachment', function() { + var incomingFilepath = path.resolve(storePath, 'incoming-file.txt'); + fs.writeFileSync(incomingFilepath, 'Hello, World!'); + var type = 'text/plain'; + var hash = 'abcdef0123456789abcdef0123456789'; + var _canonical_uri = 'test-canonical-uri'; + attachmentStore.adoptAttachment(incomingFilepath, type, hash, _canonical_uri); + expect(fs.existsSync(path.resolve(storePath, 'files', hash))).toBe(true); + }); + + it('getAttachmentStream', function() { + var options = { + text: 'Hello, World!', + type: 'text/plain', + filename: 'data.txt', + }; + var contentHash = attachmentStore.saveAttachment(options); + var stream = attachmentStore.getAttachmentStream(contentHash); + expect(stream).not.toBeNull(); + expect(stream.type).toBe('text/plain'); + }); - it('getAttachmentFileSize', function() { - var options = { - text: 'Hello, World!', - type: 'text/plain', - reference: 'test-reference', - }; - var contentHash = attachmentStore.saveAttachment(options); - var fileSize = attachmentStore.getAttachmentFileSize(contentHash); - expect(fileSize).toBe(13); - }); + it('getAttachmentFileSize', function() { + var options = { + text: 'Hello, World!', + type: 'text/plain', + reference: 'test-reference', + }; + var contentHash = attachmentStore.saveAttachment(options); + var fileSize = attachmentStore.getAttachmentFileSize(contentHash); + expect(fileSize).toBe(13); + }); - it('getAttachmentMetadata', function() { - var options = { - text: 'Hello, World!', - type: 'text/plain', - filename: 'data.txt', - }; - var contentHash = attachmentStore.saveAttachment(options); - var metadata = attachmentStore.getAttachmentMetadata(contentHash); - expect(metadata).not.toBeNull(); - expect(metadata.type).toBe('text/plain'); - expect(metadata.filename).toBe('data.txt'); - }); + it('getAttachmentMetadata', function() { + var options = { + text: 'Hello, World!', + type: 'text/plain', + filename: 'data.txt', + }; + var contentHash = attachmentStore.saveAttachment(options); + var metadata = attachmentStore.getAttachmentMetadata(contentHash); + expect(metadata).not.toBeNull(); + expect(metadata.type).toBe('text/plain'); + expect(metadata.filename).toBe('data.txt'); + }); - it('saveAttachment large file', async function() { - var sizeInMB = 10 - const file = await generateFileWithSize('./editions/test/test-store/large-file.txt', 1024 * 1024 * sizeInMB) - var options = { - text: file, - type: 'application/octet-stream', - reference: 'test-reference', - }; - var contentHash = attachmentStore.saveAttachment(options); - assert.strictEqual(contentHash.length, 64); - assert.strictEqual(fs.existsSync(path.resolve(storePath, 'files', contentHash)), true); - }); + it('saveAttachment large file', async function() { + var sizeInMB = 10 + const file = await generateFileWithSize('./editions/test/test-store/large-file.txt', 1024 * 1024 * sizeInMB) + var options = { + text: file, + type: 'application/octet-stream', + reference: 'test-reference', + }; + var contentHash = attachmentStore.saveAttachment(options); + assert.strictEqual(contentHash.length, 64); + assert.strictEqual(fs.existsSync(path.resolve(storePath, 'files', contentHash)), true); + }); - it('saveAttachment multiple large files', async function() { - var sizeInMB = 10; - var numFiles = 5; - for (var i = 0; i < numFiles; i++) { - const file = await generateFileWithSize(`./editions/test/test-store/large-file-${i}.txt`, 1024 * 1024 * sizeInMB); - var options = { - text: file, - type: 'application/octet-stream', - reference: `test-reference-${i}`, - }; - var contentHash = attachmentStore.saveAttachment(options); - assert.strictEqual(contentHash.length, 64); - assert.strictEqual(fs.existsSync(path.resolve(storePath, 'files', contentHash)), true); - } - }); + it('saveAttachment multiple large files', async function() { + var sizeInMB = 10; + var numFiles = 5; + for (var i = 0; i < numFiles; i++) { + const file = await generateFileWithSize(`./editions/test/test-store/large-file-${i}.txt`, 1024 * 1024 * sizeInMB); + var options = { + text: file, + type: 'application/octet-stream', + reference: `test-reference-${i}`, + }; + var contentHash = attachmentStore.saveAttachment(options); + assert.strictEqual(contentHash.length, 64); + assert.strictEqual(fs.existsSync(path.resolve(storePath, 'files', contentHash)), true); + } + }); it('getAttachmentStream multiple large files', async function() { var sizeInMB = 10; diff --git a/plugins/tiddlywiki/multiwikiserver/scripts/admin-dropdown.js b/plugins/tiddlywiki/multiwikiserver/scripts/admin-dropdown.js new file mode 100644 index 000000000..58336e65f --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/scripts/admin-dropdown.js @@ -0,0 +1,19 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/scripts/admin-dropdown.js +type: application/javascript + +\*/ + +(function () { +document.addEventListener("click", function (event) { + var dropdown = document.querySelector(".mws-admin-dropdown-content"); + var dropbtn = document.querySelector(".mws-admin-dropbtn"); + if(!event.target.matches(".mws-admin-dropbtn")) { + if(dropdown.style.display === "block") { + dropdown.style.display = "none"; + } + } else { + dropdown.style.display = dropdown.style.display === "block" ? "none" : "block"; + } +}); +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/templates/add-user-form.tid b/plugins/tiddlywiki/multiwikiserver/templates/add-user-form.tid new file mode 100644 index 000000000..6d1eb9169 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/add-user-form.tid @@ -0,0 +1,93 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/add-user-form + +

Add New User

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ <$button class="btn btn-primary"> + Add User + <> + <$action-sendmessage $message="tm-close-tiddler"/> + +
+
+ + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/get-index.tid b/plugins/tiddlywiki/multiwikiserver/templates/get-index.tid index 9b64a1ea7..897e54017 100644 --- a/plugins/tiddlywiki/multiwikiserver/templates/get-index.tid +++ b/plugins/tiddlywiki/multiwikiserver/templates/get-index.tid @@ -21,7 +21,11 @@ title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-index \end -! Wikis Available Here +<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header"> + <$set name="page-title" value="Wikis Available Here"> + <$transclude/> + +
    <$list filter="[jsonindexes[]] :sort[jsonget[recipe_name]]" variable="recipe-index"> @@ -69,7 +73,6 @@ title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-index
-
Create a new recipe or modify and existing one @@ -149,4 +152,78 @@ title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-index <%endif%> - \ No newline at end of file + + + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/get-users.tid b/plugins/tiddlywiki/multiwikiserver/templates/get-users.tid new file mode 100644 index 000000000..c265b6c67 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/get-users.tid @@ -0,0 +1,124 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-users + +\define edit-user-actions(user-id) + <$action-sendmessage $message="tm-modal" $param="$:/plugins/tiddlywiki/multiwikiserver/templates/edit-user-modal" user-id=<>/> +\end + +\define delete-user-actions(user-id) + <$action-sendmessage $message="tm-server-request" + method="DELETE" + url={{{ [[$:/admin/users/]addsuffix] }}} + redirectAfterSuccess="/admin/users"/> +\end + +<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header"> + <$set name="page-title" value="User Management"> + <$transclude/> + + + +
+
+ <$list filter="[jsonindexes[]]" variable="user-index"> + <$let currentUser={{{ [jsonextract] }}}> + <$set name="user-id" value={{{ [jsonget[user_id]] }}}> + ] }}} class="user-item"> + +
+ + Created: <$text text={{{ [jsonget[created_at]] }}}/> + + +
+
+ + + +
+ + <$list filter="[match[yes]]"> +
+ <$transclude tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/add-user-form" mode="inline"/> +
+ +
+ + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/manage-acl.tid b/plugins/tiddlywiki/multiwikiserver/templates/manage-acl.tid new file mode 100644 index 000000000..024f5a3a7 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/manage-acl.tid @@ -0,0 +1,267 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/manage-acl + +<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header"> + <$set name="page-title" value="Manage ACL"> + <$transclude /> + + + + +
+

Recipe ACL: <$text text={{{ [jsonget[recipe_name]] }}}/>

+
+
+

Add Recipe ACL Record

+
+ + jsonget[recipe_name]] }}}/> + jsonget[bag_name]] }}}/> + + + + + +
+
+
+ + + + + + + + + + <$list filter="[jsonindexes[]]" variable="acl-index"> + <$let acl={{{ [jsonextract] }}}> + + + + + + + + +
RolePermissionAction
+ <$text text={{{ [jsonget[role_name]] }}}/> + + <$text text={{{ [jsonget[permission_name]] }}}/> + (<$text text={{{ [jsonget[permission_description]] }}}/>) + +
+ jsonget[acl_id]] }}}/> + + jsonget[recipe_name]] }}}/> + jsonget[bag_name]] }}}/> + +
+
+
+
+
+ +
+

Bag ACL: <$text text={{{ [jsonget[bag_name]] }}}/>

+
+
+

Add Bag ACL Record

+
+ + jsonget[recipe_name]] }}}/> + jsonget[bag_name]] }}}/> + + + + + +
+
+
+ + + + + + + + + + <$list filter="[jsonindexes[]]" variable="acl-index"> + <$let acl={{{ [jsonextract] }}}> + + + + + + + + +
RolePermissionAction
+ <$text text={{{ [jsonget[role_name]] }}}/> + + <$text text={{{ [jsonget[permission_name]] }}}/> + (<$text text={{{ [jsonget[permission_description]] }}}/>) + +
+ jsonget[acl_id]] }}}/> + + jsonget[recipe_name]] }}}/> + jsonget[bag_name]] }}}/> + +
+
+
+
+
+ + \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/templates/manage-roles.tid b/plugins/tiddlywiki/multiwikiserver/templates/manage-roles.tid new file mode 100644 index 000000000..a7ad9d284 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/manage-roles.tid @@ -0,0 +1,175 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/manage-roles + +\define add-role-actions() + <$action-sendmessage $message="tm-server-request" + method="POST" + url="/admin/roles" + headers="Content-Type: application/json" + body={{{ [{"name": "$(newRoleName)$", "description": "$(newRoleDescription)$"}jsonify[]] }}} + redirectAfterSuccess="/admin/roles"/> + <$action-setfield $tiddler="$:/temp/newRoleName" text=""/> + <$action-setfield $tiddler="$:/temp/newRoleDescription" text=""/> +\end + +\define edit-role-actions(role-id) + <$action-sendmessage $message="tm-server-request" + method="PUT" + url={{{ [[$:/admin/roles/]addsuffix] }}} + headers="Content-Type: application/json" + body={{{ [{"name": "$(newRoleName)$", "description": "$(newRoleDescription)$"}jsonify[]] }}} + redirectAfterSuccess="/admin/roles"/> +\end + +\define delete-role-actions(role-id) + <$action-sendmessage $message="tm-server-request" + method="DELETE" + url={{{ [[$:/admin/roles/]addsuffix] }}} + redirectAfterSuccess="/admin/roles"/> +\end + +<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header"> + <$set name="page-title" value="Manage Roles"> + <$transclude/> + + + +
+
+

Existing Roles

+ <$list filter="[jsonindexes[]]" variable="role-index"> + <$let role={{{ [jsonextract] }}}> +
+
+ + <$text text={{{ [jsonget[role_name]] }}}/> + + + <$text text={{{ [jsonget[description]] }}}/> + +
+
+ <$button class="tc-btn-invisible btn-edit"> + Edit + <$action-sendmessage $message="tm-modal" $param="$:/plugins/tiddlywiki/multiwikiserver/templates/edit-role-modal" role-id={{{ [jsonget[role_id]] }}}/> + +
+ jsonget[role_id]] }}}/> + +
+
+
+ + +
+ +
+

Add New Role

+
+ + + +
+
+
+ + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account.tid b/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account.tid new file mode 100644 index 000000000..9e8003258 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account.tid @@ -0,0 +1,111 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account + + + + \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/templates/manage-user.tid b/plugins/tiddlywiki/multiwikiserver/templates/manage-user.tid new file mode 100644 index 000000000..1c632a062 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/manage-user.tid @@ -0,0 +1,158 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/manage-user + +<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header"> + <$set name="page-title" value="User Profile"> + <$transclude/> + + +
+ + + <% if [match[yes]] %> + <$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account"> + <$transclude/> + + <% elseif [match[yes]] %> + <$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account"> + <$transclude/> + + <% endif %> + + <$let flash-message={{{ [[$:/state/mws/flash-message]get[text]] }}}> + <$reveal type="nomatch" state="$:/state/mws/flash-message" text=""> +
+ <$text text=<>/> +
+ <$action-setfield $tiddler="$:/state/mws/flash-message" text=""/> + + +
+ + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/mws-header.tid b/plugins/tiddlywiki/multiwikiserver/templates/mws-header.tid new file mode 100644 index 000000000..269125fbc --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/mws-header.tid @@ -0,0 +1,94 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/mws-header + +
+

<$text text=<>/>

+ +
+ +