1
0
mirror of https://github.com/Jermolene/TiddlyWiki5 synced 2024-11-27 03:57:21 +00:00

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
This commit is contained in:
webplusai 2024-10-30 18:59:44 +01:00 committed by GitHub
parent 5d6ddaee7e
commit 6a7612ddf8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 3966 additions and 307 deletions

View File

@ -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

View File

@ -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;
})();

View File

@ -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=<<tv-config-toolbar-class>>>
<$action-managetiddler tiddler=<<currentTiddler>>/>
{{$:/core/images/globe}}
<%if [<tv-config-toolbar-text>match[yes]] %>
<span class="tc-btn-text">
<$text text={{$:/language/Buttons/ManageACL/Caption}}/>
</span>
<%endif%>
</$button>

View File

@ -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]]

View File

@ -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"
]
}
}

26
package-lock.json generated
View File

@ -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"
},

View File

@ -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"
}
}

View File

@ -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;
})();

View File

@ -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;
})();

View File

@ -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"/>
<html>
<head>
<$transclude tiddler="$:/plugins/tiddlywiki/multiwikiserver/auth/form/login/head"/>
</head>
<body>
<div class="login-container">
<$transclude tiddler="$:/plugins/tiddlywiki/multiwikiserver/auth/form/login/header" mode="block"/>
<$transclude tiddler="$:/plugins/tiddlywiki/multiwikiserver/auth/form/login/form" mode="block"/>
<$transclude tiddler="$:/plugins/tiddlywiki/multiwikiserver/auth/form/login/error-message" mode="block"/>
</div>
</body>
</html>

View File

@ -0,0 +1,7 @@
title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/error-message
<$list filter="[[$:/temp/mws/login/error]!is[missing]]" variable="errorTiddler">
<div class="tc-error-message">
{{$:/temp/mws/login/error}}
</div>
</$list>

View File

@ -0,0 +1,8 @@
title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/form
<form class="login-form" method="POST" action="/login">
<input type="hidden" name="returnUrl" value=<<returnUrl>>/>
<input type="text" name="username" placeholder="Username"/>
<input type="password" name="password" placeholder="Password"/>
<input type="submit" value="Log In"/>
</form>

View File

@ -0,0 +1,3 @@
title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/head
<title>TiddlyWiki Login</title>

View File

@ -0,0 +1,3 @@
title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/header
<h1>TiddlyWiki Login</h1>

View File

@ -0,0 +1,48 @@
title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/styles
<style>
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.login-container {
max-width: 300px;
padding: 20px;
background-color: #ffffff;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.login-container h1 {
text-align: center;
color: #333;
margin-bottom: 20px;
}
.login-form input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.login-form input[type="submit"] {
background-color: #4CAF50;
color: white;
cursor: pointer;
border: none;
}
.login-form input[type="submit"]:hover {
background-color: #45a049;
}
.tc-error-message {
color: #ff0000;
text-align: center;
margin-top: 10px;
}
</style>

View File

@ -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.

View File

@ -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 <permission_name> <description>";
}
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;
})();

View File

@ -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 <role_name> <description>";
}
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;
})();

View File

@ -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 <username> <password> [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;
})();

View File

@ -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 <role_name> <permission_name>";
}
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;
})();

View File

@ -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 <username> <role_name>";
}
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;
})();

View File

@ -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",

View File

@ -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

View File

@ -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();
};
}());

View File

@ -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();
};
}());

View File

@ -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();
}
}
};

View File

@ -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();
};
}());

View File

@ -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();
};
}());

View File

@ -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();
}
};
}());

View File

@ -16,11 +16,14 @@ fallback=<url> // 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;
}
}

View File

@ -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();
}
}
};

View File

@ -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);

View File

@ -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");
}
};
}());

View File

@ -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;
}
};

View File

@ -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();
};

View File

@ -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();
};
}());

View File

@ -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]),

View File

@ -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();
};
}());

View File

@ -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();
};
}());

View File

@ -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();
};
}());

View File

@ -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(`
<!doctype html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
</head>
<body>
`);
// 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(`
</body>
</html>
`);
response.end();
if(!response.headersSent) {
response.writeHead(200, "OK",{
"Content-Type": "text/html"
});
response.write(`
<!doctype html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
</head>
<body>
`);
// 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(`
</body>
</html>
`);
response.end();
}
}
}
});

View File

@ -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);

View File

@ -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();
};
}());

View File

@ -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();
};
}());

View File

@ -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);

View File

@ -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();
};
}());

View File

@ -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();
};
}());

View File

@ -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();
}
}
};

View File

@ -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();
}
};
}());

View File

@ -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();
}
}
};

View File

@ -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();
};
}());

View File

@ -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;
}
}
};
})();

View File

@ -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;
})();
})();

View File

@ -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();
});
}
})();

View File

@ -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;

View File

@ -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";
}
});
})();

View File

@ -0,0 +1,93 @@
title: $:/plugins/tiddlywiki/multiwikiserver/templates/add-user-form
<h1>Add New User</h1>
<form method="POST" action="/admin/post-user">
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username" class="form-input" required>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email" class="form-input" required>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password" class="form-input" required>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password:</label>
<input type="password" id="confirmPassword" name="confirmPassword" class="form-input" required>
</div>
<div class="form-actions">
<$button class="btn btn-primary">
Add User
<<add-user-actions>>
<$action-sendmessage $message="tm-close-tiddler"/>
</$button>
</div>
</form>
<style>
.add-user-form {
background-color: #f8f9fa;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.add-user-form h1 {
margin-top: 0;
margin-bottom: 20px;
color: #333;
font-size: 1.5em;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #555;
}
.form-input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-actions {
margin-top: 20px;
display: flex;
gap: 1rem;
flex-direction: row;
justify-content: center;
align-items: center;
}
.btn {
width: 100%;
margin: auto;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s ease;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
</style>

View File

@ -21,7 +21,11 @@ title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-index
</$genesis>
\end
! Wikis Available Here
<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header">
<$set name="page-title" value="Wikis Available Here">
<$transclude/>
</$set>
</$tiddler>
<ul class="mws-vertical-list">
<$list filter="[<recipe-list>jsonindexes[]] :sort[<currentTiddler>jsonget[recipe_name]]" variable="recipe-index">
@ -69,7 +73,6 @@ title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-index
</li>
</$list>
</ul>
<form action="/recipes" method="post" class="mws-form">
<div class="mws-form-heading">
Create a new recipe or modify and existing one
@ -149,4 +152,78 @@ title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-index
<%endif%>
<label for="chkShowSystem">Show system bags</label>
<button type="submit">Update</button>
</form>
</form>
<style>
.mws-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #f0f0f0;
margin-bottom: 20px;
}
.mws-user-info {
display: flex;
align-items: center;
}
.mws-logout-form {
margin-left: 10px;
}
.mws-logout-button {
padding: 5px 10px;
background-color: #f44336;
color: white;
border: none;
cursor: pointer;
}
.mws-logout-button:hover {
background-color: #d32f2f;
}
.mws-admin-dropdown {
position: relative;
display: inline-block;
margin-left: 10px;
}
.mws-admin-dropbtn {
color: white;
padding: 5px;
font-size: 16px;
border: none;
cursor: pointer;
}
.mws-admin-dropbtn:hover, .mws-admin-dropbtn:focus {
cursor: pointer;
opacity: 0.8;
}
.mws-admin-dropdown-content {
display: none;
position: absolute;
background-color: #f1f1f1;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
right: 0;
}
.mws-admin-dropdown-content a {
color: black;
padding: 12px 16px;
text-decoration: none;
display: block;
}
.mws-admin-dropdown-content a:hover {background-color: #ddd;}
.mws-admin-dropdown:hover .mws-admin-dropdown-content {display: block;}
.mws-admin-dropdown:hover {background-color: #2980B9;}
</style>

View File

@ -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=<<user-id>>/>
\end
\define delete-user-actions(user-id)
<$action-sendmessage $message="tm-server-request"
method="DELETE"
url={{{ [[$:/admin/users/]addsuffix<user-id>] }}}
redirectAfterSuccess="/admin/users"/>
\end
<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header">
<$set name="page-title" value="User Management">
<$transclude/>
</$set>
</$tiddler>
<div class="users-container">
<div class="users-list">
<$list filter="[<user-list>jsonindexes[]]" variable="user-index">
<$let currentUser={{{ [<user-list>jsonextract<user-index>] }}}>
<$set name="user-id" value={{{ [<currentUser>jsonget[user_id]] }}}>
<a href={{{ [[/admin/users/]addsuffix<user-id>] }}} class="user-item">
<div class="user-info">
<span class="user-name">
<$text text={{{ [<currentUser>jsonget[username]] }}}/>
</span>
<span class="user-email">
<$text text={{{ [<currentUser>jsonget[email]] }}}/>
</span>
</div>
<div class="user-details">
<span class="user-created">
Created: <$text text={{{ [<currentUser>jsonget[created_at]] }}}/>
</span>
<span class="user-last-login">
Last Login: <$text text={{{ [<currentUser>jsonget[last_login]] }}}/>
</span>
</div>
</a>
</$set>
</$let>
</$list>
</div>
<$list filter="[<user-is-admin>match[yes]]">
<div class="add-user-card">
<$transclude tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/add-user-form" mode="inline"/>
</div>
</$list>
</div>
<style>
.users-container {
margin: auto;
max-width: 1200px;
display: flex;
justify-content: space-between;
gap: 2rem;
}
.users-list {
flex: 1;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 2rem;
}
.add-user-card {
width: 300px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 2rem;
}
.user-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
border-bottom: 1px solid #eee;
padding: 1rem 0;
cursor: pointer;
transition: background-color 0.3s ease;
text-decoration: none;
}
.user-item:hover {
background-color: #f5f5f5;
text-decoration: none;
}
.user-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-name {
font-weight: bold;
}
.user-email {
color: #666;
}
.user-details {
font-size: 0.9em;
color: #888;
margin-top: 0.5rem;
}
.add-user-form {
display: none; /* Hide the original add user button */
}
.tc-btn-big-green {
background-color: #4CAF50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
}
.tc-btn-big-green:hover {
background-color: #45a049;
}
</style>

View File

@ -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 />
</$set>
</$tiddler>
<div class="container">
<h2>Recipe ACL: <$text text={{{ [<recipe>jsonget[recipe_name]] }}}/></h2>
<div class="acl-section">
<div class="acl-form">
<h3>Add Recipe ACL Record</h3>
<form method="POST" action="/admin/post-acl">
<input type="hidden" name="entity_type" value="recipe" />
<input type="hidden" name="recipe_name" value={{{ [<recipe>jsonget[recipe_name]] }}}/>
<input type="hidden" name="bag_name" value={{{ [<bag>jsonget[bag_name]] }}}/>
<select name="role_id" class="tc-select">
<option value="">Select Role</option>
<$list filter="[<roles-list>jsonindexes[]]" variable="role-index">
<$let role={{{ [<roles-list>jsonextract<role-index>] }}}>
<option value={{{ [<role>jsonget[role_id]] }}}><$text text={{{ [<role>jsonget[role_name]] }}}/></option>
</$let>
</$list>
</select>
<select name="permission_id" class="tc-select">
<option value="">Select Permission</option>
<$list filter="[<permissions-list>jsonindexes[]]" variable="permission-index">
<$let permission={{{ [<permissions-list>jsonextract<permission-index>] }}}>
<option value={{{ [<permission>jsonget[permission_id]] }}}><$text text={{{ [<permission>jsonget[permission_name]] }}}/></option>
</$let>
</$list>
</select>
<button type="submit" class="tc-btn-invisible btn-add">
Add ACL Record
</button>
</form>
</div>
<div class="acl-table">
<table>
<thead>
<tr>
<th>Role</th>
<th>Permission</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<$list filter="[<recipe-acl-records>jsonindexes[]]" variable="acl-index">
<$let acl={{{ [<recipe-acl-records>jsonextract<acl-index>] }}}>
<tr>
<td>
<$text text={{{ [<acl>jsonget[role_name]] }}}/>
</td>
<td>
<$text text={{{ [<acl>jsonget[permission_name]] }}}/>
(<small><$text text={{{ [<acl>jsonget[permission_description]] }}}/></small>)
</td>
<td>
<form method="POST" action="/admin/delete-acl">
<input type="hidden" name="acl_id" value={{{ [<acl>jsonget[acl_id]] }}}/>
<input type="hidden" name="entity_type" value="recipe" />
<input type="hidden" name="recipe_name" value={{{ [<recipe>jsonget[recipe_name]] }}}/>
<input type="hidden" name="bag_name" value={{{ [<bag>jsonget[bag_name]] }}}/>
<button type="submit" class="btn btn-delete">Delete</button>
</form>
</td>
</tr>
</$let>
</$list>
</tbody>
</table>
</div>
</div>
</div>
<div class="container">
<h2>Bag ACL: <$text text={{{ [<bag>jsonget[bag_name]] }}}/></h2>
<div class="acl-section">
<div class="acl-form">
<h3>Add Bag ACL Record</h3>
<form method="POST" action="/admin/post-acl">
<input type="hidden" name="entity_type" value="bag" />
<input type="hidden" name="recipe_name" value={{{ [<recipe>jsonget[recipe_name]] }}}/>
<input type="hidden" name="bag_name" value={{{ [<bag>jsonget[bag_name]] }}}/>
<select name="role_id" class="tc-select">
<option value="">Select Role</option>
<$list filter="[<roles-list>jsonindexes[]]" variable="role-index">
<$let role={{{ [<roles-list>jsonextract<role-index>] }}}>
<option value={{{ [<role>jsonget[role_id]] }}}><$text text={{{ [<role>jsonget[role_name]] }}}/></option>
</$let>
</$list>
</select>
<select name="permission_id" class="tc-select">
<option value="">Select Permission</option>
<$list filter="[<permissions-list>jsonindexes[]]" variable="permission-index">
<$let permission={{{ [<permissions-list>jsonextract<permission-index>] }}}>
<option value={{{ [<permission>jsonget[permission_id]] }}}><$text text={{{ [<permission>jsonget[permission_name]] }}}/></option>
</$let>
</$list>
</select>
<button type="submit" class="tc-btn-invisible btn-add">
Add ACL Record
</button>
</form>
</div>
<div class="acl-table">
<table>
<thead>
<tr>
<th>Role</th>
<th>Permission</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<$list filter="[<bag-acl-records>jsonindexes[]]" variable="acl-index">
<$let acl={{{ [<bag-acl-records>jsonextract<acl-index>] }}}>
<tr>
<td>
<$text text={{{ [<acl>jsonget[role_name]] }}}/>
</td>
<td>
<$text text={{{ [<acl>jsonget[permission_name]] }}}/>
(<small><$text text={{{ [<acl>jsonget[permission_description]] }}}/></small>)
</td>
<td>
<form method="POST" action="/admin/delete-acl">
<input type="hidden" name="acl_id" value={{{ [<acl>jsonget[acl_id]] }}}/>
<input type="hidden" name="entity_type" value="bag" />
<input type="hidden" name="recipe_name" value={{{ [<recipe>jsonget[recipe_name]] }}}/>
<input type="hidden" name="bag_name" value={{{ [<bag>jsonget[bag_name]] }}}/>
<button type="submit" class="btn btn-delete">Delete</button>
</form>
</td>
</tr>
</$let>
</$list>
</tbody>
</table>
</div>
</div>
</div>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f4f4f4;
}
.container {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 20px;
}
h3 {
margin: 0px;
margin-bottom: 10px;
}
h1,
h2 {
color: #2c3e50;
}
.acl-section {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.acl-form {
flex: 1;
min-width: 250px;
}
.acl-table {
flex: 2;
min-width: 300px;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin-bottom: 20px;
}
th,
td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
background-color: #f8f9fa;
font-weight: bold;
}
.btn {
padding: 8px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-delete {
background-color: #e74c3c;
color: white;
padding: 4px 10px;
margin-top: 2px;
margin-bottom: 2px;
}
.btn-delete:hover {
background-color: #c0392b;
}
.btn-add {
background-color: #3498db;
color: white;
}
.btn-add:hover {
background-color: #2980b9;
}
select,
.tc-btn-invisible {
padding: 8px;
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 4px;
width: 100%;
}
.form-group {
margin-bottom: 15px;
}
@media (max-width: 768px) {
.acl-section {
flex-direction: column;
}
.acl-form,
.acl-table {
width: 100%;
}
}
</style>

View File

@ -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<role-id>] }}}
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<role-id>] }}}
redirectAfterSuccess="/admin/roles"/>
\end
<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header">
<$set name="page-title" value="Manage Roles">
<$transclude/>
</$set>
</$tiddler>
<div class="roles-container">
<div class="roles-list">
<h2>Existing Roles</h2>
<$list filter="[<roles-list>jsonindexes[]]" variable="role-index">
<$let role={{{ [<roles-list>jsonextract<role-index>] }}}>
<div class="role-item">
<div class="role-info">
<span class="role-name">
<$text text={{{ [<role>jsonget[role_name]] }}}/>
</span>
<span class="role-description">
<$text text={{{ [<role>jsonget[description]] }}}/>
</span>
</div>
<div class="role-actions">
<$button class="tc-btn-invisible btn-edit">
Edit
<$action-sendmessage $message="tm-modal" $param="$:/plugins/tiddlywiki/multiwikiserver/templates/edit-role-modal" role-id={{{ [<role>jsonget[role_id]] }}}/>
</$button>
<form method="POST" action="/admin/delete-role">
<input type="hidden" name="role_id" value={{{ [<role>jsonget[role_id]] }}}/>
<button type="submit" class="tc-btn-invisible btn-delete">Delete</button>
</form>
</div>
</div>
</$let>
</$list>
</div>
<div class="add-role-card">
<h2>Add New Role</h2>
<form method="POST" action="/admin/post-role" class="add-role-form">
<input name="role_name" type="text" placeholder="Role Name" required/>
<input name="role_description" type="text" placeholder="Role Description" required/>
<button type="submit" class="tc-btn-invisible btn-add">Add Role</button>
</form>
</div>
</div>
<style>
.roles-container {
max-width: 1200px;
margin: 2rem auto;
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.roles-list {
flex: 1 1 60%;
min-width: 300px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 2rem;
}
.add-role-card {
flex: 1 1 30%;
min-width: 250px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 2rem;
align-self: flex-start;
}
.role-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid #eee;
}
.role-info {
flex-grow: 1;
}
.role-name {
font-weight: bold;
display: block;
margin-bottom: 0.5rem;
}
.role-description {
color: #666;
font-size: 0.9em;
}
.role-actions {
display: flex;
gap: 0.5rem;
}
.role-actions button {
padding: 0.5rem 1rem;
background: none;
border: none;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.3s;
}
.btn-edit {
color: #007bff;
}
.btn-delete {
color: #dc3545;
}
.btn-edit:hover, .btn-delete:hover {
background-color: #f8f9fa;
}
.add-role-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.add-role-form input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.btn-add {
padding: 0.5rem 1rem;
background-color: #28a745;
color: white;
border: none;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.3s;
}
.btn-add:hover {
background-color: #218838;
}
h2 {
margin-top: 0;
margin-bottom: 1rem;
color: #333;
}
@media (max-width: 768px) {
.roles-container {
flex-direction: column;
}
.roles-list, .add-role-card {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,111 @@
title: $:/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account
<div class="user-profile-management">
<h2>Manage Account</h2>
<$set name="current-role-id" value={{{ [<user-role>jsonget[role_id]] }}}>
<form class="user-profile-form" action="/update-user-profile" method="POST">
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username" value={{{ [<user>jsonget[username]] }}} required />
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email" value={{{ [<user>jsonget[email]] }}} required />
</div>
<% if [<user-is-admin>match[yes]] %>
<div class="form-group">
<label for="role">Role:</label>
<select id="role" name="role" required>
<$list filter="[<all-roles>jsonindexes[]]" variable="role-index">
<$set name="role-id" value={{{ [<all-roles>jsonextract<role-index>jsonget[role_id]] }}}>
<option value=<<role-id>>
<$list filter="[<current-role-id>match<role-id>]" variable="ignore">selected</$list>
<$text text={{{ [<all-roles>jsonextract<role-index>jsonget[role_name]] }}}/>
</option>
</$set>
</$list>
</select>
</div>
<% endif %>
<button type="submit" class="update-profile-btn">Update Profile</button>
</form>
</$set>
<hr />
<h2>Change Password</h2>
<form class="user-profile-form" action="/change-user-password" method="POST">
<div class="form-group">
<label for="new-password">New Password:</label>
<input type="password" id="new-password" name="newPassword" required />
</div>
<div class="form-group">
<label for="confirm-password">Confirm New Password:</label>
<input type="password" id="confirm-password" name="confirmPassword" required />
</div>
<button type="submit" class="update-password-btn">Change Password</button>
</form>
</div>
<style>
.user-profile-management {
padding: 20px;
}
.user-profile-management h2 {
font-size: 1.2rem;
color: #3498db;
margin-bottom: 1rem;
}
.user-profile-form {
margin-bottom: 20px;
}
.user-profile-form .form-group {
margin-bottom: 1rem;
}
.user-profile-form label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: #555;
}
.user-profile-form input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.update-profile-btn,
.update-password-btn {
background: #3498db;
color: #fff;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.update-password-btn {
background: #00796b;
}
.update-profile-btn:hover {
background: #2980b9;
}
.update-password-btn:hover {
background: #00695c;
}
.user-profile-form select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
background-color: white;
}
</style>

View File

@ -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/>
</$set>
</$tiddler>
<div class="main-wrapper">
<div class="user-profile-container">
<div class="user-profile-header">
<div class="user-profile-avatar">
<$text text={{{ [<user>jsonget[username]substr[0,1]uppercase[]] }}}/>
</div>
<h1 class="user-profile-name"><$text text={{{ [<user>jsonget[username]] }}}/></h1>
<p class="user-profile-email"><$text text={{{ [<user>jsonget[email]] }}}/></p>
</div>
<div class="user-profile-details">
<div class="user-profile-item">
<span class="user-profile-label">User ID:</span>
<span class="user-profile-value"><$text text={{{ [<user>jsonget[user_id]] }}}/></span>
</div>
<div class="user-profile-item">
<span class="user-profile-label">Created At:</span>
<span class="user-profile-value"><$text text={{{ [<user>jsonget[created_at]split[T]first[]] }}}/></span>
</div>
<div class="user-profile-item">
<span class="user-profile-label">Last Login:</span>
<span class="user-profile-value"><$text text={{{ [<user>jsonget[last_login]split[T]first[]] }}}/></span>
</div>
<div class="user-profile-roles">
<h2>User Role</h2>
<ul>
<li>
<$text text={{{ [<user-role>jsonget[role_name]] }}}/>
</li>
</ul>
</div>
</div>
</div>
<% if [<user-is-admin>match[yes]] %>
<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account">
<$transclude/>
</$tiddler>
<% elseif [<is-current-user-profile>match[yes]] %>
<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account">
<$transclude/>
</$tiddler>
<% endif %>
<$let flash-message={{{ [[$:/state/mws/flash-message]get[text]] }}}>
<$reveal type="nomatch" state="$:/state/mws/flash-message" text="">
<div class="flash-message">
<$text text=<<flash-message>>/>
</div>
<$action-setfield $tiddler="$:/state/mws/flash-message" text=""/>
</$reveal>
</$let>
</div>
<style>
.main-wrapper {
display: flex;
flex-direction: row;
gap: 5px;
max-width: 80vw;
margin: auto;
}
.user-profile-container {
flex: 1;
margin: 2rem auto;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
max-width: 600px;
}
.user-profile-header {
background: #3498db;
color: #fff;
padding: 2rem;
text-align: center;
}
.user-profile-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
margin: 0 auto 1rem;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
}
.user-profile-avatar * {
color: #3498db;
}
.user-profile-name {
font-size: 1.5rem;
margin: 0;
}
.user-profile-email {
font-size: 1rem;
opacity: 0.8;
margin: 0.5rem 0 0;
}
.user-profile-details {
padding: 2rem;
}
.user-profile-item {
margin-bottom: 1rem;
}
.user-profile-label {
font-weight: bold;
color: #555;
}
.user-profile-value {
color: #333;
}
.user-profile-roles {
margin-top: 2rem;
}
.user-profile-roles h2 {
font-size: 1.2rem;
color: #3498db;
margin-bottom: 1rem;
}
.user-profile-roles ul {
list-style-type: none;
padding: 0;
margin: 0;
}
.user-profile-roles li {
background: #f1f1f1;
padding: 0.5rem 1rem;
border-radius: 20px;
display: inline-block;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
</style>

View File

@ -0,0 +1,94 @@
title: $:/plugins/tiddlywiki/multiwikiserver/templates/mws-header
<div class="mws-header">
<h1><$text text=<<page-title>>/></h1>
<div class="mws-user-info">
<span>Hello, <$text text=<<username>>/></span>
<$list filter="[<user-is-admin>match[yes]]">
<div class="mws-admin-dropdown">
<button class="mws-admin-dropbtn">⚙️</button>
<div class="mws-admin-dropdown-content">
<a href="/admin/users">Manage Users</a>
<a href="/admin/roles">Manage Roles</a>
</div>
</div>
</$list>
<form action="/logout" method="post" class="mws-logout-form">
<input type="submit" value="Logout" class="mws-logout-button"/>
</form>
</div>
</div>
<style>
.mws-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #f0f0f0;
margin-bottom: 20px;
}
.mws-user-info {
display: flex;
align-items: center;
}
.mws-logout-form {
margin-left: 10px;
}
.mws-logout-button {
padding: 5px 10px;
background-color: #f44336;
color: white;
border: none;
cursor: pointer;
}
.mws-logout-button:hover {
background-color: #d32f2f;
}
.mws-admin-dropdown {
position: relative;
display: inline-block;
margin-left: 10px;
}
.mws-admin-dropbtn {
color: white;
padding: 5px;
font-size: 16px;
border: none;
cursor: pointer;
}
.mws-admin-dropbtn:hover, .mws-admin-dropbtn:focus {
cursor: pointer;
opacity: 0.8;
}
.mws-admin-dropdown-content {
display: none;
position: absolute;
background-color: #f1f1f1;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
right: 0;
}
.mws-admin-dropdown-content a {
color: black;
padding: 12px 16px;
text-decoration: none;
display: block;
}
.mws-admin-dropdown-content a:hover {background-color: #ddd;}
.mws-admin-dropdown:hover .mws-admin-dropdown-content {display: block;}
.mws-admin-dropdown:hover {background-color: #2980B9;}
</style>