1
0
mirror of https://github.com/Jermolene/TiddlyWiki5 synced 2025-12-08 17:58:05 +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
64 changed files with 3966 additions and 307 deletions

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;