diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75c3bb8d329..af030458e03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,9 @@ on: branches: - master - tiddlywiki-com + - multi-wiki-support env: - NODE_VERSION: "22" + NODE_VERSION: "23" jobs: test: runs-on: ubuntu-latest @@ -82,3 +83,14 @@ jobs: - run: "./bin/build-tw-org.sh" env: GH_TOKEN: ${{ secrets.GITHUBPUSHTOKEN }} + build-mws-tiddlywiki-com: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/multi-wiki-support' + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: "${{ env.NODE_VERSION }}" + - run: "./bin/build-mws-site.sh" + env: + GH_TOKEN: ${{ secrets.GITHUBPUSHTOKEN }} diff --git a/.gitignore b/.gitignore index 412759161ed..ce0198ede61 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,9 @@ tmp/ output/ node_modules/ +store/ /test-results/ /playwright-report/ /playwright/.cache/ $__StoryList.tid +/editions/test/test-store/* \ No newline at end of file diff --git a/TODO BEFORE MERGING THIS PR.md b/TODO BEFORE MERGING THIS PR.md new file mode 100644 index 00000000000..6398c646d85 --- /dev/null +++ b/TODO BEFORE MERGING THIS PR.md @@ -0,0 +1,4 @@ +The `multi-wiki-support` branch includes some changes that are not intended to be merged into the `master` branch: + +* Readme update (see `editions/tw5.com/tiddlers/readme/ReadMe.tid`) +* Remove `multiwikiserver` plugin from `readme-bld.sh` (see `bin/readme-bld.sh`) diff --git a/bin/build-mws-site.sh b/bin/build-mws-site.sh new file mode 100755 index 00000000000..4f06c252f78 --- /dev/null +++ b/bin/build-mws-site.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +# Build mws.tiddlywiki.com assets. + +# Default to the version of TiddlyWiki installed in this repo + +if [ -z "$MWSTWCOM_BUILD_TIDDLYWIKI" ]; then + MWSTWCOM_BUILD_TIDDLYWIKI=./tiddlywiki.js +fi + +echo "Using MWSTWCOM_BUILD_TIDDLYWIKI as [$MWSTWCOM_BUILD_TIDDLYWIKI]" + +# Set up the build details + +if [ -z "$MWSTWCOM_BUILD_DETAILS" ]; then + MWSTWCOM_BUILD_DETAILS="$(git symbolic-ref --short HEAD)-$(git rev-parse HEAD) from $(git remote get-url origin)" +fi + +echo "Using MWSTWCOM_BUILD_DETAILS as [$MWSTWCOM_BUILD_DETAILS]" + +if [ -z "$MWSTWCOM_BUILD_COMMIT" ]; then + MWSTWCOM_BUILD_COMMIT="$(git rev-parse HEAD)" +fi + +echo "Using MWSTWCOM_BUILD_COMMIT as [$MWSTWCOM_BUILD_COMMIT]" + +# Set up the build output directory + +if [ -z "$MWSTWCOM_BUILD_OUTPUT" ]; then + MWSTWCOM_BUILD_OUTPUT=$(mktemp -d) +fi + +mkdir -p $MWSTWCOM_BUILD_OUTPUT + +if [ ! -d "$MWSTWCOM_BUILD_OUTPUT" ]; then + echo 'A valid MWSTWCOM_BUILD_OUTPUT environment variable must be set' + exit 1 +fi + +echo "Using MWSTWCOM_BUILD_OUTPUT as [$MWSTWCOM_BUILD_OUTPUT]" + +# Pull existing GitHub pages content + +git clone --depth=1 --branch=main "https://github.com/TiddlyWiki/mws.tiddlywiki.com-gh-pages.git" $MWSTWCOM_BUILD_OUTPUT + +# Make the CNAME file that GitHub Pages requires + +echo "mws.tiddlywiki.com" > $MWSTWCOM_BUILD_OUTPUT/CNAME + +# Delete any existing static content + +mkdir -p $MWSTWCOM_BUILD_OUTPUT/static +rm $MWSTWCOM_BUILD_OUTPUT/static/* + +# Put the build details into a .tid file so that it can be included in each build (deleted at the end of this script) + +echo -e -n "title: $:/build\ncommit: $MWSTWCOM_BUILD_COMMIT\n\n$MWSTWCOM_BUILD_DETAILS\n" > $MWSTWCOM_BUILD_OUTPUT/build.tid + +###################################################### +# +# mws.tiddlywiki.com distribution +# +###################################################### + +# /index.html Main site +# /favicon.ico Favicon for main site +# /static.html Static rendering of default tiddlers +# /alltiddlers.html Static rendering of all tiddlers +# /static/* Static single tiddlers +# /static/static.css Static stylesheet +# /static/favicon.ico Favicon for static pages +node $MWSTWCOM_BUILD_TIDDLYWIKI \ + editions/multiwikidocs \ + --verbose \ + --version \ + --load $MWSTWCOM_BUILD_OUTPUT/build.tid \ + --output $MWSTWCOM_BUILD_OUTPUT \ + --build favicon static index \ + || exit 1 + +# Delete the temporary build tiddler + +rm $MWSTWCOM_BUILD_OUTPUT/build.tid || exit 1 + +# Push output back to GitHub + +# Exit script immediately if any command fails +set -e + +pushd $MWSTWCOM_BUILD_OUTPUT +git config --global user.email "actions@github.com" +git config --global user.name "GitHub Actions" +git add -A . +git commit --message "GitHub build: $GITHUB_RUN_NUMBER of $TW5_BUILD_BRANCH ($(date +'%F %T %Z'))" +git remote add deploy "https://$GH_TOKEN@github.com/TiddlyWiki/mws.tiddlywiki.com-gh-pages.git" &>/dev/null +git push deploy main &>/dev/null +popd diff --git a/bin/ci-test.sh b/bin/ci-test.sh index ffcae66b263..6f25a7378ea 100755 --- a/bin/ci-test.sh +++ b/bin/ci-test.sh @@ -2,6 +2,8 @@ # test TiddlyWiki5 for tiddlywiki.com +npm install + node ./tiddlywiki.js \ ./editions/test \ --verbose \ diff --git a/bin/readme-bld.sh b/bin/readme-bld.sh index e7c9df56471..c4653119e58 100755 --- a/bin/readme-bld.sh +++ b/bin/readme-bld.sh @@ -10,6 +10,7 @@ fi # tw5.com readmes node $TW5_BUILD_TIDDLYWIKI \ + +plugins/tiddlywiki/multiwikiserver \ editions/tw5.com \ --verbose \ --output . \ diff --git a/core/modules/commander.js b/core/modules/commander.js index b55679a2e8e..127b7a6981f 100644 --- a/core/modules/commander.js +++ b/core/modules/commander.js @@ -38,6 +38,13 @@ Commander.prototype.log = function(str) { } }; +/* +Clear pending commands +*/ +Commander.prototype.clearCommands = function() { + this.commandTokens = this.commandTokens.slice(0,this.nextToken); +}; + /* Write a string if verbose flag is set */ diff --git a/core/modules/commands/listen.js b/core/modules/commands/listen.js index ca6e6e07654..acaecf38db2 100644 --- a/core/modules/commands/listen.js +++ b/core/modules/commands/listen.js @@ -16,7 +16,7 @@ var Server = require("$:/core/modules/server/server.js").Server; exports.info = { name: "listen", - synchronous: true, + synchronous: false, namedParameterMode: true, mandatoryParameters: [] }; @@ -38,7 +38,11 @@ Command.prototype.execute = function() { wiki: this.commander.wiki, variables: self.params }); - var nodeServer = this.server.listen(); + var nodeServer = this.server.listen(null,null,null,{ + callback: function() { + self.callback(); + } + }); $tw.hooks.invokeHook("th-server-command-post-start",this.server,nodeServer,"tiddlywiki"); return null; }; diff --git a/core/modules/commands/quit.js b/core/modules/commands/quit.js new file mode 100644 index 00000000000..721f0090778 --- /dev/null +++ b/core/modules/commands/quit.js @@ -0,0 +1,37 @@ +/*\ +title: $:/core/modules/commands/quit.js +type: application/javascript +module-type: command + +Immediately ends the TiddlyWiki process + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "quit", + synchronous: true +}; + +var Command = function(params,commander,callback) { + var self = this; + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + // Clear any pending commands + this.commander.clearCommands(); + // We don't actually quit, we just issue the "th-quit" hook to give listeners a chance to exit + $tw.hooks.invokeHook("th-quit"); + return null; +}; + +exports.Command = Command; + +})(); diff --git a/core/modules/server/server.js b/core/modules/server/server.js index d3c98f8fc2c..a700fb14699 100644 --- a/core/modules/server/server.js +++ b/core/modules/server/server.js @@ -364,6 +364,11 @@ Server.prototype.listen = function(port,host,prefix) { } // Display the port number after we've started listening (the port number might have been specified as zero, in which case we will get an assigned port) server.on("listening",function() { + // Stop listening when we get the "th-quit" hook + $tw.hooks.addHook("th-quit",function() { + server.close(); + }); + // Log listening details var address = server.address(), url = self.protocol + "://" + (address.family === "IPv6" ? "[" + address.address + "]" : address.address) + ":" + address.port + prefix; $tw.utils.log("Serving on " + url,"brown/orange"); diff --git a/core/modules/syncer.js b/core/modules/syncer.js index f7627e1ac53..dd35472fed3 100644 --- a/core/modules/syncer.js +++ b/core/modules/syncer.js @@ -257,7 +257,11 @@ Save an incoming tiddler in the store, and updates the associated tiddlerInfo Syncer.prototype.storeTiddler = function(tiddlerFields) { // Save the tiddler var tiddler = new $tw.Tiddler(tiddlerFields); - this.wiki.addTiddler(tiddler); + // Only save the tiddler if it has changed + var existingTiddler = this.wiki.getTiddler(tiddlerFields.title); + if(!existingTiddler || !existingTiddler.isEqual(tiddler)) { + this.wiki.addTiddler(tiddler); + } // Save the tiddler revision and changeCount details this.tiddlerInfo[tiddlerFields.title] = { revision: this.getTiddlerRevision(tiddlerFields.title), @@ -556,6 +560,7 @@ SaveTiddlerTask.prototype.run = function(callback) { // Invoke the callback callback(null); },{ + syncer: self.syncer, tiddlerInfo: self.syncer.tiddlerInfo[self.title] }); } else { @@ -586,6 +591,7 @@ DeleteTiddlerTask.prototype.run = function(callback) { // Invoke the callback callback(null); },{ + syncer: self.syncer, tiddlerInfo: self.syncer.tiddlerInfo[this.title] }); }; @@ -614,6 +620,8 @@ LoadTiddlerTask.prototype.run = function(callback) { } // Invoke the callback callback(null); + },{ + syncer: self.syncer }); }; diff --git a/core/modules/widgets/image.js b/core/modules/widgets/image.js index 52496fd7421..73c9d8cb6f6 100644 --- a/core/modules/widgets/image.js +++ b/core/modules/widgets/image.js @@ -163,4 +163,4 @@ ImageWidget.prototype.refresh = function(changedTiddlers) { exports.image = ImageWidget; -})(); +})(); \ No newline at end of file diff --git a/editions/multiwikidocs/tiddlers/Bags and Recipes.tid b/editions/multiwikidocs/tiddlers/Bags and Recipes.tid new file mode 100644 index 00000000000..b0e3f3cf3d7 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/Bags and Recipes.tid @@ -0,0 +1,41 @@ +created: 20240309135835396 +modified: 20240309142156125 +title: Bags and Recipes +type: text/vnd.tiddlywiki + +The bags and recipes model is a reference architecture for how tiddlers can be shared between multiple wikis. It was first introduced by TiddlyWeb in 2008. + +The principles of bags and recipes can be simply stated: + +# Tiddlers are stored in named "bags" +# Bags have access controls that determines which users can read or write to them +# Recipes are named lists of bags, ordered from lowest priority to highest +# The tiddlers within a recipe are accumulated in turn from each bag in the recipe in order of increasing priority. Thus, if there are multiple tiddlers with the same title in different bags then the one from the highest priority bag will be used as the recipe tiddler +# Wikis are composed by splicing the tiddlers from the corresponding recipe into the standard TW5 HTML template + +A very simple example of the recipe/bag model might be for a single user who maintains the following bags: + +* ''recipes'' - tiddlers related to cooking recipes +* ''work'' - tiddlers related to work +* ''app'' - common tiddlers for customising TiddlyWiki + +Those bags would be used with the following recipes: + +* ''recipes'' --> recipes, app - wiki for working with recipes, with common custom components +* ''work'' --> work, app - wiki for working with work, with common custom components +* ''app'' --> app - wiki for maintaining custom components + +All of this will work dynamically, so changes to the app bag will instantly ripple into the affected hosted wikis. + +A more complex example might be for a teacher working with a group of students: + +* ''student-{name}'' bag for each students work +* ''teacher-course'' bag for the coursework, editable by the teacher +* ''teacher-tools'' bag for custom tools used by the teacher + +Those bags would be exposed through the following hosted wikis: + +* ''student-{name}'' hosted wiki for each students work, including the coursework material +* ''teacher-course'' hosted wiki for the coursework, editable by the teacher +* ''teacher'' hosted wiki for the teacher, bringing together all the bags, giving them an overview of all the students work + diff --git a/editions/multiwikidocs/tiddlers/Database Engines.tid b/editions/multiwikidocs/tiddlers/Database Engines.tid new file mode 100644 index 00000000000..257bea34fbb --- /dev/null +++ b/editions/multiwikidocs/tiddlers/Database Engines.tid @@ -0,0 +1,15 @@ +title: Database Engines +tags: Reference + +MWS uses [[SQLite]] for data storage. It supports choosing between two "database engines" that are based on two different npm modules: + +* [[better-sqlite3|https://www.npmjs.com/package/better-sqlite3]] is written partially in C/C++ and so requires compilation for the target platform +* [[node-sqlite3-wasm|https://www.npmjs.com/package/node-sqlite3-wasm]] is written entirely in JavaScript and does not require compilation, but does require a WebAssembly-capable Node.js host. This is not currently possible on some platforms such as iOS/iPadOS + +By default `npm install` will install both database engines. By default it will use `better-sqlite3`. To switch to using `node-sqlite3-wasm`, set the system configuration tiddler `$:/config/MultiWikiServer/Engine` to `wasm` (the default value is `better`). Note that this tiddler resides in the [[Administration Wiki]]. + +!! Avoiding Installation Errors + +If you encounter errors during `npm install` related to `gyp` or `prebuild`, you may be able to avoid them by using `node-sqlite3-wasm` instead of `better-sqlite3`. However, it will be necessary to manually install `node-sqlite3-wasm` and its dependencies. This can be done by running the following commands in your terminal: + +<<.copy-code-to-clipboard "npm install node-sqlite3-wasm">> diff --git a/editions/multiwikidocs/tiddlers/DefaultTiddlers.tid b/editions/multiwikidocs/tiddlers/DefaultTiddlers.tid new file mode 100644 index 00000000000..0cd3074838d --- /dev/null +++ b/editions/multiwikidocs/tiddlers/DefaultTiddlers.tid @@ -0,0 +1,6 @@ +title: $:/DefaultTiddlers + +HelloThere +Installation +Usage +Reference \ No newline at end of file diff --git a/editions/multiwikidocs/tiddlers/HelloThere.tid b/editions/multiwikidocs/tiddlers/HelloThere.tid new file mode 100644 index 00000000000..0d74fd3023b --- /dev/null +++ b/editions/multiwikidocs/tiddlers/HelloThere.tid @@ -0,0 +1,19 @@ +title: HelloThere +tags: TableOfContents + +!! ~TiddlyWiki is Growing Up + +[img width=200 [MWS Banner.png]] +~MultiWikiServer is a new development that drastically improves ~TiddlyWiki's capabilities when running as a server under Node.js. It brings ~TiddlyWiki up to par with common web-based tools like ~WordPress or ~MediaWiki by supporting multiple wikis and multiple users at the same time. + +Features under development include: + +* Hosting multiple wikis at once, using the [[Bags and Recipes]] mechanism for sharing data between them +* Based on [[SQLite|MWS and SQLite]] for performance and reliability +* Robust built-in synchronisation handlers for syncing data to the filesystem +* Flexible authentication and authorisation options +* Improved handling of file uploads and attachments, allowing gigabyte video files to be uploaded and streamed +* Instantaneous synchronisation of changes between the server and all connected clients +* Workflow processing on the server, for example to automatically compress images, or to archive webpages + +MWS is currently [[under development at GitHub|https://github.com/TiddlyWiki/TiddlyWiki5/pull/7915]] but it is already functional and usable. diff --git a/editions/multiwikidocs/tiddlers/Installation using Git.tid b/editions/multiwikidocs/tiddlers/Installation using Git.tid new file mode 100644 index 00000000000..2bbd2dd24f6 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/Installation using Git.tid @@ -0,0 +1,19 @@ +title: Installation using Git +tags: Installation +modified: 20241105133737778 +created: 20241105133737778 + +These instructions require basic knowledge both of the terminal and of Git. There are also [[alternative instructions without using Git|Installation]]. + +# Clone the code from GitHub with: <<.copy-code-to-clipboard "git clone -b multi-wiki-support --single-branch https://github.com/TiddlyWiki/TiddlyWiki5">> +# Open a terminal window and set the current directory to the root of the downloaded folder +# Install the dependencies with the command: <<.copy-code-to-clipboard "npm install">> +# Start the server with the command: <<.copy-code-to-clipboard "npm start">> +# To use MWS, visit http://localhost:8080 in a browser on the same computer +# When you have finished using MWS, stop the server with ctrl-C + +See [[Troubleshooting]] if you encounter any errors. + +To update your copy of MWS with newer changes from ~GitHub, run the following command: + +<<.copy-code-to-clipboard "git pull">> diff --git a/editions/multiwikidocs/tiddlers/Installation.tid b/editions/multiwikidocs/tiddlers/Installation.tid new file mode 100644 index 00000000000..bc436a3e928 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/Installation.tid @@ -0,0 +1,18 @@ +title: Installation +tags: TableOfContents +modified: 20241105133737778 +created: 20241105133737778 + +These instructions require minimal knowledge of the terminal. There are also [[alternative instructions for those using Git|Installation using Git]]. + +# Download the code [[direct from GitHub|https://github.com/TiddlyWiki/TiddlyWiki5/archive/refs/pull/7915/head.zip]] +# Open a terminal window and set the current directory to the root of the downloaded folder +# Install the dependencies with the command: <<.copy-code-to-clipboard "npm install">> +# To verify that MWS is working correctly, start the server with the command: <<.copy-code-to-clipboard "npm start">> and then visit http://localhost:8080 in a browser on the same computer +# When you have finished using MWS, stop the server with ctrl-C + +See [[Troubleshooting]] if you encounter any errors. + + + +To update your copy of MWS in the future with newer changes will require re-downloading the code, taking care not to lose any changes you might have made. diff --git a/editions/multiwikidocs/tiddlers/MWS Architecture.tid b/editions/multiwikidocs/tiddlers/MWS Architecture.tid new file mode 100644 index 00000000000..7eac6e44853 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/MWS Architecture.tid @@ -0,0 +1,2 @@ +title: Architecture +tags: TableOfContents diff --git a/editions/multiwikidocs/tiddlers/MWS and SQLite.tid b/editions/multiwikidocs/tiddlers/MWS and SQLite.tid new file mode 100644 index 00000000000..202e7d8e8f5 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/MWS and SQLite.tid @@ -0,0 +1,25 @@ +title: MWS and SQLite +tags: Architecture + +! Introduction + +SQLite is a very popular open source embedded SQL database with some [[unusual characteristics|https://www.sqlite.org/different.html]]. It has proved itself to be robust, fast and scalable, and has been widely adopted in a range of applications including web browsers, mobile devices, and embedded systems. + +The "embedded" part means that developers access SQLite as a library of C functions that run as part of a larger application. This contrasts with more familiar database applications like Microsoft's SQL Server or Oracle that are accessed as network services. + +MWS uses SQLite for the tiddler store and associated data. It brings many advantages: + +* ''Performance'': the optimising query engine inside SQLite makes it much faster and more efficient than could be achieved in plain JavaScript. In some cases, it is [[faster than writing directly to the file system||https://www.sqlite.org/fasterthanfs.html]] +* ''Reliability'': SQLite uses protocols that [[ensure data integrity and consistency|https://www.sqlite.org/hirely.html]], even when the application crashes +* ''Scalability'': SQLite can handle extremely [[large datasets and complex queries|https://www.sqlite.org/limits.html]] +* ''Portability'': SQLite databases are stored as [[a single file|https://www.sqlite.org/fileformat.html]] that can be easily copied and moved between systems + +! Misconceptions + +TiddlyWiki 5 has always incorporated a database. Until MWS, that database has always been a custom tiddler database written in JavaScript. Over the years it has been enhanced and optimised with indexes and other database features that have given us reasonably decent performance for a range of common operations. + +In terms of the traditional architecture of TiddlyWiki, MWS uses SQLite as the basis for an internal API that is equivalent to that of the `$tw.Wiki` object: basic CRUD operations on a database of tiddlers stored by their titles. + +In the context of MWS, SQLite is just a fast and efficient equivalent of TiddlyWiki's existing JavaScript database engine. It gives us the option of persisting the database in file storage, but we also retain the option to run the database entirely within memory and rely on a file synchronisation process to save changes as individual `.tid` files in the file system, just as we do today. + +One particular misconception to avoid is the idea that SQLite replaces the folders of `.tid` files that characterise the Node.js configuration of TiddlyWiki. These are separate components with a different purpose. The tiddler files are the result of syncing a database to the filesystem, and that database can be conceptually interchanged between our custom JavaScript database or the new SQLite implementation in MWS. diff --git a/editions/multiwikidocs/tiddlers/Reference.tid b/editions/multiwikidocs/tiddlers/Reference.tid new file mode 100644 index 00000000000..627bbac3e2a --- /dev/null +++ b/editions/multiwikidocs/tiddlers/Reference.tid @@ -0,0 +1,230 @@ +title: Reference +tags: TableOfContents + +! 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 + +# Multiple User Accounts +# Role-based Access Control +# Granular Permissions +# Access Control List (ACL) + +!! Application Access & Security + +!!! Initial Setup +When you first launch the Multiwiki Server, it operates in an unauthenticated mode to facilitate initial configuration. During this initial state, the system creates a temporary anonymous administrator account. Upon accessing the landing page, you'll receive a prominent security warning with instructions to establish a permanent ADMIN account. It's crucial to create this account immediately to secure your installation. + +!!! User Types and Permissions + +!!!! Administrator (ADMIN) + +* Full system access and configuration rights +* Can create, modify, and delete user accounts +* Manages role assignments and permissions +* Controls global system settings +* Can configure guest access policies +* Has complete control over all recipes and tiddlers + +!!!! Regular Users +* Custom accounts created by administrators +* Permissions determined by assigned roles +* Access limited to specific recipes based on role permissions +* Can have READ and/or WRITE permissions + +!!!! Guest Users +* Default anonymous access level +* No inherent permissions +* Can only access recipes without Access Control Lists (ACLs) +* Read/write capabilities configurable by ADMIN +* Useful for public wikis or documentation sites + +!!! Access Control System + +!!!! Recipe-Level Security +* Access control is implemented at the recipe level +* Each recipe can have its own Access Control List (ACL) +* Permissions are granular: +** READ: Allows viewing recipe contents +** WRITE: Allows modifications to recipe contents + +!!!! Role-Based Access Control (RBAC) +* Administrators can create custom roles +* Roles can be assigned specific READ/WRITE permissions +* Users inherit permissions from their assigned roles +* Multiple roles can be assigned to a single user +* Provides flexible and scalable access management + +!!!! Permission Inheritance +* Users receive combined permissions from all assigned roles +* When roles grant different permission levels for the same resource, the higher access level is granted. For example, if one role grants "read" and another grants "write" access to a recipe, the user receives "write" access since it includes all lower-level permissions. +* Guest access is overridden by recipe ACLs +* When different permission rules conflict, the system follows a "most restrictive wins" principle: if any applicable rule denies access or requires a higher security level, that restriction takes precedence over more permissive rules. This ensures security is maintained even when users have multiple overlapping role assignments or inherited permissions. + +This security model allows for fine-grained control over content access while maintaining flexibility for both private and public wiki deployments. + +!! User Management & Security Architecture + +!!! User Account Management + +Users can be administered through two interfaces: + +# Web-based Administrative Interface +#* Accessible only to ADMIN users +#* Provides graphical interface for user operations +#* Real-time validation and feedback +# Command-line Interface (CLI) Tools +#* Suitable for automation and scripting +#* Enables batch operations +#* Useful for system initialization + +Each user account contains the following essential components: + +* ''Username'' +** Must be unique across the system +** Case-sensitive +** Used for authentication and audit trails +* ''Password'' +** Stored using secure hashing algorithms +** Never stored in plaintext +** Subject to complexity requirements +* ''Role Assignments'' +** Multiple roles can be assigned +** Inherited permissions from all assigned roles +** Dynamic permission calculation based on role combination + +!!! Role & Permission Framework + +!!!! Role Management + +Roles serve as permission containers and provide organized access control. The system includes: + +Built-in Roles: + +* `ADMIN` +** Highest privilege level +** Full system access +** Cannot be modified or deleted +** Can create and manage other roles +** Controls guest access policies + +* `USER` +** Basic access rights +** Typically limited to specific recipes + +**Custom Roles (Examples):** + +* `MANAGER` +** Intermediate access level +** Can manage subset of resources +** Custom roles as needed for specific use cases + +!!!! Permission Architecture + +Core Permissions: + +* `READ` Permission +** View recipe contents +** Access tiddler data +** View metadata +** Export capabilities + +* `WRITE` Permission +** Create new tiddlers +** Modify existing content +** Delete resources +** Manage recipe contents + +**Guest Access:** + +* No default permissions +* Access limited to non-ACL recipes +* Configurable by ADMIN users +* Suitable for public wikis + +!!! Access Control List (ACL) Implementation + +The ACL system provides granular security control through: + +!!!! Entity-Level Control + +* Recipe-based access control +* Individual resource protection +* Hierarchical permission inheritance + +!!! Authentication Process Flow + +* Initial Authentication +** User submits credentials +** System validates username existence +** Password hash comparison +** Session token generation + +* Session Management +** Secure session storage +** Token-based authentication +** Automatic session expiration +** Re-authentication requirements + +!!! Authorization Workflow + +* Request Processing +** Capture user action request +** Identify target resource +** Extract required permissions + +* Permission Validation +** Check user roles +** Aggregate permissions +** Verify ACL entries +** Apply guest policies if applicable + +* Access Decision +** Compare required vs. available permissions +** Apply most restrictive policy +** Return access decision + +!!! System Extension Guidelines + +!!!! Adding New Roles + +# Access administrative interface +# Define role identifier +# Assign base permissions +# Configure ACL mappings +# Test role functionality + +!!!! Permission Expansion + +# Define new permission type +# Update ACL structure +# Implement permission checks +# Update validation logic +# Document new permission + +!!!! Security Considerations + +* Regular permission audits +* Role assignment reviews +* Guest access monitoring +* Security log analysis +* Access pattern monitoring + +This comprehensive security model provides flexible, maintainable, and secure access control while supporting both authenticated and guest users within the Multiwiki Server environment. + +! HTTP API + +The ~MultiWikiServer HTTP API provides access to resources hosted by the MWS store. It is based on [[the API of TiddlyWeb|https://tank.peermore.com/tanks/tiddlyweb/HTTP%20API]], first developed in 2008 by Chris Dent. + +The design goals of the API are: + +* To follow the principles of REST where practical +* To present resources as nouns, not verbs + +General points about the design: + +* In MWS there are no resources that end with / (except for the root path which is /) diff --git a/editions/multiwikidocs/tiddlers/Troubleshooting gyp-prebuild Installation Errors.tid b/editions/multiwikidocs/tiddlers/Troubleshooting gyp-prebuild Installation Errors.tid new file mode 100644 index 00000000000..5da0ece6bac --- /dev/null +++ b/editions/multiwikidocs/tiddlers/Troubleshooting gyp-prebuild Installation Errors.tid @@ -0,0 +1,19 @@ +title: Troublesheeting gyp/prebuild Installation Errors +tags: Troubleshooting +modified: 20241105133737778 +created: 20241105133737778 + +Installation may fail with errors related to `gyp` or `prebuild`. These errors are caused by missing dependencies or incorrect versions of dependencies. + +Note that in most cases, these errors occur because of the use of the npm module [[better-sqlite3|https://www.npmjs.com/package/better-sqlite3]]. This module is mostly written in C, and thus requires compilation for the target platform. MWS supports switchable database engines, and also supports the use of the [[node-sqlite3-wasm|https://www.npmjs.com/package/node-sqlite3-wasm]] module which is written in ~JavaScript and does not require compilation and so may avoid these errors. See [[Database Engines]] for more details of how to switch between engines. + +The following steps may help resolve errors involving `gyp` or `prebuild`: + +# Ensure that you have the latest version of Node.js installed. You can download the latest version from the [[Node.js website|https://nodejs.org/]]. +# Update npm to the latest version by running the following command in your terminal: <<.copy-code-to-clipboard "npm install -g npm@latest">> +# Clear the npm cache by running the following command in your terminal: <<.copy-code-to-clipboard "npm cache clean --force">> +# Delete the `node_modules` folder in your TiddlyWiki directory by running the following command in your terminal: <<.copy-code-to-clipboard "rm -rf node_modules">> +# Reinstall the dependencies by running the following command in your terminal: <<.copy-code-to-clipboard "npm install">> +# If you continue to encounter errors, try running the following command in your terminal: <<.copy-code-to-clipboard "npm rebuild">> +# If you are still experiencing issues, you may need to manually install the `gyp` and `prebuild` dependencies. You can do this by running the following commands in your terminal: <<.copy-code-to-clipboard "npm install -g node-gyp">> <<.copy-code-to-clipboard "npm install -g prebuild">> +# Once you have installed the dependencies, try reinstalling the TiddlyWiki dependencies by running the following command in your terminal: <<.copy-code-to-clipboard "npm install">> diff --git a/editions/multiwikidocs/tiddlers/Troubleshooting.tid b/editions/multiwikidocs/tiddlers/Troubleshooting.tid new file mode 100644 index 00000000000..92ea1421c12 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/Troubleshooting.tid @@ -0,0 +1,4 @@ +title: Troubleshooting +tags: TableOfContents + +<> diff --git a/editions/multiwikidocs/tiddlers/Usage.tid b/editions/multiwikidocs/tiddlers/Usage.tid new file mode 100644 index 00000000000..ff061a3ea9a --- /dev/null +++ b/editions/multiwikidocs/tiddlers/Usage.tid @@ -0,0 +1,13 @@ +title: Usage +tags: TableOfContents +modified: 20241105133737778 +created: 20241105133737778 + +Once MWS is successfully [[installed|Installation]], you can access it by visiting http://localhost:8080 in a browser on the same computer. + +By default, MWS is installed with full anonymous access enabled, meaning that anyone with access to the server has full access to read and modify anything. However, also by default, the server is only accessible to browsers on the same machine. + +If you intend to put an MWS installation on a public network like the Internet, the server will need to be secured with the following steps: + +* Create and login with a new administrator account +* Disable anonymouse access diff --git a/editions/multiwikidocs/tiddlers/images/MWS Banner.png b/editions/multiwikidocs/tiddlers/images/MWS Banner.png new file mode 100644 index 00000000000..1e27e8f25e7 Binary files /dev/null and b/editions/multiwikidocs/tiddlers/images/MWS Banner.png differ diff --git a/editions/multiwikidocs/tiddlers/images/MWS Banner.png.meta b/editions/multiwikidocs/tiddlers/images/MWS Banner.png.meta new file mode 100644 index 00000000000..12fc2107222 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/images/MWS Banner.png.meta @@ -0,0 +1,4 @@ +title: MWS Banner.png +type: image/png +tags: picture +alt-text: Banner for the new Multi Wiki Server plugin for TiddlyWiki diff --git a/editions/multiwikidocs/tiddlers/system/ContributionBanner.tid b/editions/multiwikidocs/tiddlers/system/ContributionBanner.tid new file mode 100644 index 00000000000..5f0ff43711d --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/ContributionBanner.tid @@ -0,0 +1,16 @@ +list-after: $:/core/ui/EditTemplate/title +tags: $:/tags/EditTemplate +title: $:/ContributionBanner + +\define base-github() +https://github.com/TiddlyWiki/TiddlyWiki5/edit/multi-wiki-support/editions/multiwikidocs/tiddlers/ +\end + +<$set name="draft-of" value={{{ [get[draft.of]] }}}> +<$list filter="[[$:/config/OriginalTiddlerPaths]getindex]" variable="target" > +
+{{$:/core/images/star-filled}} Can you help us improve this documentation? [[Find out how|Improving TiddlyWiki Documentation]] to +addprefix] }}} class="tc-tiddlylink-external" target="_blank" rel="noopener noreferrer">edit it directly on ~GitHub +
+ + diff --git a/editions/multiwikidocs/tiddlers/system/SiteDomain.tid b/editions/multiwikidocs/tiddlers/system/SiteDomain.tid new file mode 100644 index 00000000000..93411d67271 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/SiteDomain.tid @@ -0,0 +1,3 @@ +title: $:/SiteDomain + +mws.tiddlywiki.com \ No newline at end of file diff --git a/editions/multiwikidocs/tiddlers/system/SitePreviewUrl.tid b/editions/multiwikidocs/tiddlers/system/SitePreviewUrl.tid new file mode 100644 index 00000000000..6af0ba2e564 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/SitePreviewUrl.tid @@ -0,0 +1,3 @@ +title: $:/SitePreviewUrl + +https://tiddlywiki.com/images/Introduction%2520Video%2520Thumbnail.jpg \ No newline at end of file diff --git a/editions/multiwikidocs/tiddlers/system/SiteSubtitle.tid b/editions/multiwikidocs/tiddlers/system/SiteSubtitle.tid new file mode 100644 index 00000000000..54046e91684 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/SiteSubtitle.tid @@ -0,0 +1,3 @@ +title: $:/SiteSubtitle + +~TiddlyWiki for the People \ No newline at end of file diff --git a/editions/multiwikidocs/tiddlers/system/SiteTitle.tid b/editions/multiwikidocs/tiddlers/system/SiteTitle.tid new file mode 100644 index 00000000000..f5e0722c056 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/SiteTitle.tid @@ -0,0 +1,4 @@ +title: $:/SiteTitle +type: text/vnd.tiddlywiki + +~TiddlyWiki ~MultiWikiServer \ No newline at end of file diff --git a/editions/multiwikidocs/tiddlers/system/SiteUrl.tid b/editions/multiwikidocs/tiddlers/system/SiteUrl.tid new file mode 100644 index 00000000000..8720c2d835b --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/SiteUrl.tid @@ -0,0 +1,3 @@ +title: $:/SiteUrl + +https://mws.tiddlywiki.com/ \ No newline at end of file diff --git a/editions/multiwikidocs/tiddlers/system/SplashScreen.tid b/editions/multiwikidocs/tiddlers/system/SplashScreen.tid new file mode 100644 index 00000000000..43da558b0d0 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/SplashScreen.tid @@ -0,0 +1,129 @@ +tags: $:/tags/RawMarkupWikified/TopBody +title: $:/SplashScreen +type: text/vnd.tiddlywiki + +\import [subfilter{$:/core/config/GlobalImportFilter}] + +\procedure show-icon(title) +<$wikify name="icon" text={{{ [addprefix[{{]addsuffix[}}]] }}} output="html"> +<$text text=<<icon>>/> +</$wikify> +\end + +\rules only filteredtranscludeinline transcludeinline macrocallinline +<div class="tc-remove-when-wiki-loaded"> +<style scoped> + +.tc-splash-text { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 16px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + margin: 20px auto 0; + width: 200px; + text-align: center; + color: <<colour foreground>>; + fill: <<colour foreground>>; +} + +.tc-splash-text img { + width: 150px; + <<box-shadow "2px 2px 10px rgba(0, 0, 0, 0.5)">> +} + +html body.tc-body { + background: <<colour page-background>>; +} + +/* +Spinner from https://github.com/tobiasahlin/SpinKit/ by Tobias Ahlin + +The MIT License (MIT) + +Copyright (c) 2015 Tobias Ahlin + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +.tc-splash-spinner { + margin: 20px auto 0; + width: 70px; + text-align: center; +} + +.tc-splash-spinner > div { + width: 18px; + height: 18px; + border-radius: 100%; + display: inline-block; + -webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both; + animation: sk-bouncedelay 1.4s infinite ease-in-out both; + background-color: #f88; +} + +.tc-splash-spinner .tc-splash-bounce-1 { + -webkit-animation-delay: -0.32s; + animation-delay: -0.32s; + background-color: #8f8; +} + +.tc-splash-spinner .tc-splash-bounce-2 { + -webkit-animation-delay: -0.16s; + animation-delay: -0.16s; + background-color: #88f; +} + +@-webkit-keyframes sk-bouncedelay { + 0%, 80%, 100% { -webkit-transform: scale(0) } + 40% { -webkit-transform: scale(1.0) } +} + +@keyframes sk-bouncedelay { + 0%, 80%, 100% { + -webkit-transform: scale(0); + transform: scale(0); + } 40% { + -webkit-transform: scale(1.0); + transform: scale(1.0); + } +} +</style> + +<div class="tc-splash-spinner"> + <div class="tc-splash-bounce-1"></div> + <div class="tc-splash-bounce-2"></div> + <div class="tc-splash-bounce-3"></div> +</div> + +<div class="tc-splash-text"> +<strong>{{$:/SiteTitle}}</strong> +<br/> +is loading +</div> + +<!-- Demonstrating how to embed a bitmap graphic --> +<div class="tc-splash-text"> +<img src="data:image/jpeg;base64,{{MWS Banner.png||$:/core/templates/plain-text-tiddler}}" width="100"/> +</div> + +<!-- Demonstrating how to embed a wikitext SVG graphic --> +<div class="tc-splash-text"> +<<show-icon "$:/core/icon">> +</div> + +</div> diff --git a/editions/multiwikidocs/tiddlers/system/StaticBanner.tid b/editions/multiwikidocs/tiddlers/system/StaticBanner.tid new file mode 100644 index 00000000000..9a9ae9bfb7b --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/StaticBanner.tid @@ -0,0 +1,3 @@ +title: $:/StaticBanner + +<div class="tc-static-alert"><div class="tc-static-alert-inner">This page is part of a static HTML representation of the ~TiddlyWiki at https://tiddlywiki.com/</div></div> diff --git a/editions/multiwikidocs/tiddlers/system/Styles.tid b/editions/multiwikidocs/tiddlers/system/Styles.tid new file mode 100644 index 00000000000..c1d6c3eedfc --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/Styles.tid @@ -0,0 +1,49 @@ +tags: $:/tags/Stylesheet +title: $:/_styles +type: text/vnd.tiddlywiki + +\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock + +.tc-improvement-banner { + font-size: 0.7em; + background: #fcc; + padding-left: 5px; + margin-top: 6px; + margin-bottom: 12px; + <<box-shadow "2px 2px 2px rgba(0,0,0,0.4)">> +} + +@media (max-width: {{$:/themes/tiddlywiki/vanilla/metrics/sidebarbreakpoint}}) { + + .tc-improvement-banner { + } + +} + +@media (min-width: {{$:/themes/tiddlywiki/vanilla/metrics/sidebarbreakpoint}}) { + + .tc-improvement-banner { + margin-right: -53px; + margin-left: -53px; + } + + .tc-improvement-banner:before { + display: block; + position: absolute; + width: 0; + height: 0; + content: " "; + margin-left: -5px; + margin-top: -10px; + border-top: 5px solid transparent; + border-left: 5px solid transparent; + border-right: 5px solid #C07E7E; + border-bottom: 5px solid #C07E7E; + } + +} + +.tc-improvement-banner svg { + width: 1em; + height: 1em; +} diff --git a/editions/multiwikidocs/tiddlers/system/TableOfContents.tid b/editions/multiwikidocs/tiddlers/system/TableOfContents.tid new file mode 100644 index 00000000000..41021fa5f13 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/TableOfContents.tid @@ -0,0 +1,9 @@ +tags: $:/tags/SideBar +title: TableOfContents +list-before: + +<div class="tc-table-of-contents"> + +<<toc-selective-expandable 'TableOfContents'>> + +</div> diff --git a/editions/multiwikidocs/tiddlers/system/configDefaultSidebarTab.tid b/editions/multiwikidocs/tiddlers/system/configDefaultSidebarTab.tid new file mode 100644 index 00000000000..c097ae0b120 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/configDefaultSidebarTab.tid @@ -0,0 +1,3 @@ +title: $:/config/DefaultSidebarTab + +TableOfContents \ No newline at end of file diff --git a/editions/multiwikidocs/tiddlers/system/favicon.png b/editions/multiwikidocs/tiddlers/system/favicon.png new file mode 100644 index 00000000000..0723ec8d5cd Binary files /dev/null and b/editions/multiwikidocs/tiddlers/system/favicon.png differ diff --git a/editions/multiwikidocs/tiddlers/system/favicon.png.meta b/editions/multiwikidocs/tiddlers/system/favicon.png.meta new file mode 100644 index 00000000000..76d0be1a8ca --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/favicon.png.meta @@ -0,0 +1,2 @@ +title: $:/favicon.ico +type: image/png diff --git a/editions/multiwikidocs/tiddlers/system/macros.tid b/editions/multiwikidocs/tiddlers/system/macros.tid new file mode 100644 index 00000000000..b5bb732f2ac --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/macros.tid @@ -0,0 +1,11 @@ +code-body: yes +tags: $:/tags/Macro +title: $:/editions/multiwikidocs/doc-macros +type: text/vnd.tiddlywiki + +\procedure .copy-code-to-clipboard(text) +<div> +<$transclude $variable="copy-to-clipboard-above-right" src=<<text>>/> +<$codeblock code=<<text>>/> +</div> +\end .copy-code-to-clipboard diff --git a/editions/multiwikidocs/tiddlers/system/mws-palette.tid b/editions/multiwikidocs/tiddlers/system/mws-palette.tid new file mode 100644 index 00000000000..decf06bfdd8 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/mws-palette.tid @@ -0,0 +1,149 @@ +title: $:/palettes/MWS +name: MWS Palette +description: Palette for mws.tiddlywiki.com +tags: $:/tags/Palette +type: application/x-tiddler-dictionary + +alert-background: #ffe476 +alert-border: #b99e2f +alert-highlight: #881122 +alert-muted-foreground: #b99e2f +background: #ffffff +blockquote-bar: <<colour muted-foreground>> +button-background: +button-foreground: +button-border: +code-background: #f7f7f9 +code-border: #e1e1e8 +code-foreground: #dd1144 +diff-delete-background: #ffc9c9 +diff-delete-foreground: <<colour foreground>> +diff-equal-background: +diff-equal-foreground: <<colour foreground>> +diff-insert-background: #aaefad +diff-insert-foreground: <<colour foreground>> +diff-invisible-background: +diff-invisible-foreground: <<colour muted-foreground>> +dirty-indicator: #ff0000 +download-background: #34c734 +download-foreground: <<colour background>> +dragger-background: <<colour foreground>> +dragger-foreground: <<colour background>> +dropdown-background: <<colour background>> +dropdown-border: <<colour muted-foreground>> +dropdown-tab-background-selected: #ffffff +dropdown-tab-background: #ececec +dropzone-background: rgba(0,200,0,0.7) +external-link-background-hover: inherit +external-link-background-visited: inherit +external-link-background: inherit +external-link-foreground-hover: inherit +external-link-foreground-visited: #0000aa +external-link-foreground: #0000ee +footnote-target-background: #ecf2ff +foreground: #333333 +highlight-background: #ffff00 +highlight-foreground: #000000 +message-background: #ecf2ff +message-border: #cfd6e6 +message-foreground: #547599 +modal-backdrop: <<colour foreground>> +modal-background: <<colour background>> +modal-border: #999999 +modal-footer-background: #f5f5f5 +modal-footer-border: #dddddd +modal-header-border: #eeeeee +muted-foreground: #bbbbbb +network-activity-foreground: #448844 +notification-background: #ffffdd +notification-border: #999999 +page-background: #eddee7 +pre-background: #f5f5f5 +pre-border: #cccccc +primary: #5778d8 +selection-background: +selection-foreground: +select-tag-background: +select-tag-foreground: +sidebar-button-foreground: <<colour foreground>> +sidebar-controls-foreground-hover: #000000 +sidebar-controls-foreground: #aaaaaa +sidebar-foreground-shadow: rgba(255,255,255, 0.8) +sidebar-foreground: #acacac +sidebar-muted-foreground-hover: #444444 +sidebar-muted-foreground: #c0c0c0 +sidebar-tab-background-selected: #eddee7 +sidebar-tab-background: #c7b7bf +sidebar-tab-border-selected: <<colour tab-border-selected>> +sidebar-tab-border: <<colour tab-border>> +sidebar-tab-divider: #e4e4e4 +sidebar-tab-foreground-selected: +sidebar-tab-foreground: <<colour tab-foreground>> +sidebar-tiddler-link-foreground-hover: #444444 +sidebar-tiddler-link-foreground: #999999 +site-title-foreground: <<colour tiddler-title-foreground>> +stability-stable: #008000 +stability-experimental: #c07c00 +stability-deprecated: #ff0000 +stability-legacy: #0000ff +static-alert-foreground: #aaaaaa +tab-background-selected: #ffffff +tab-background: #d8d8d8 +tab-border-selected: #d8d8d8 +tab-border: #cccccc +tab-divider: #d8d8d8 +tab-foreground-selected: <<colour tab-foreground>> +tab-foreground: #666666 +table-border: #dddddd +table-footer-background: #a8a8a8 +table-header-background: #f0f0f0 +tag-background: #eecc66 +tag-foreground: #ffffff +testcase-accent-level-1: #c1eaff +testcase-accent-level-2: #E3B740 +testcase-accent-level-3: #5FD564 +tiddler-background: <<colour background>> +tiddler-border: <<colour background>> +tiddler-controls-foreground-hover: #888888 +tiddler-controls-foreground-selected: #444444 +tiddler-controls-foreground: #cccccc +tiddler-editor-background: #f8f8f8 +tiddler-editor-border-image: #ffffff +tiddler-editor-border: #cccccc +tiddler-editor-fields-even: #e0e8e0 +tiddler-editor-fields-odd: #f0f4f0 +tiddler-info-background: #f8f8f8 +tiddler-info-border: #dddddd +tiddler-info-tab-background: #f8f8f8 +tiddler-link-background: <<colour background>> +tiddler-link-foreground: <<colour primary>> +tiddler-subtitle-foreground: #c0c0c0 +tiddler-title-foreground: #182955 +toolbar-new-button: +toolbar-options-button: +toolbar-save-button: +toolbar-info-button: +toolbar-edit-button: +toolbar-close-button: +toolbar-delete-button: +toolbar-cancel-button: +toolbar-done-button: +untagged-background: #999999 +very-muted-foreground: #888888 +wikilist-background: #e5e5e5 +wikilist-item: #ffffff +wikilist-info: #000000 +wikilist-title: #666666 +wikilist-title-svg: <<colour wikilist-title>> +wikilist-url: #aaaaaa +wikilist-button-open: #4fb82b +wikilist-button-open-hover: green +wikilist-button-reveal: #5778d8 +wikilist-button-reveal-hover: blue +wikilist-button-remove: #d85778 +wikilist-button-remove-hover: red +wikilist-toolbar-background: #d3d3d3 +wikilist-toolbar-foreground: #888888 +wikilist-droplink-dragover: rgba(255,192,192,0.5) +wikilist-button-background: #acacac +wikilist-button-foreground: #000000 diff --git a/editions/multiwikidocs/tiddlers/system/palette.tid b/editions/multiwikidocs/tiddlers/system/palette.tid new file mode 100644 index 00000000000..0c16b0586a5 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/palette.tid @@ -0,0 +1,3 @@ +title: $:/palette + +$:/palettes/MWS \ No newline at end of file diff --git a/editions/multiwikidocs/tiddlers/system/sidebarlayout.tid b/editions/multiwikidocs/tiddlers/system/sidebarlayout.tid new file mode 100644 index 00000000000..2bfa95e13fa --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/sidebarlayout.tid @@ -0,0 +1,3 @@ +title: $:/themes/tiddlywiki/vanilla/options/sidebarlayout + +fluid-fixed \ No newline at end of file diff --git a/editions/multiwikidocs/tiddlers/system/static.content.tid b/editions/multiwikidocs/tiddlers/system/static.content.tid new file mode 100644 index 00000000000..02e62aba820 --- /dev/null +++ b/editions/multiwikidocs/tiddlers/system/static.content.tid @@ -0,0 +1,18 @@ +title: $:/core/templates/static.content + +\define tv-wikilink-template() https://tiddlywiki.com/static/$uri_doubleencoded$.html + +<!-- For Google, and people without JavaScript--> + +<$reveal default="yes" text=<<savingEmpty>> type="nomatch"> + +It looks like this browser doesn't run JavaScript. You can use one of these static HTML versions to browse the same content: + +* https://mws.tiddlywiki.com/static.html - browse individual tiddlers as separate pages +* https://mws.tiddlywiki.com/alltiddlers.html#HelloThere - single file containing all tiddlers + +--- + +{{HelloThere}} + +</$reveal> diff --git a/editions/multiwikidocs/tiddlywiki.info b/editions/multiwikidocs/tiddlywiki.info new file mode 100644 index 00000000000..6b212e69ab4 --- /dev/null +++ b/editions/multiwikidocs/tiddlywiki.info @@ -0,0 +1,26 @@ +{ + "description": "Multiple wiki server documentation edition", + "plugins": [ + ], + "themes": [ + "tiddlywiki/vanilla", + "tiddlywiki/snowwhite" + ], + "build": { + "index": [ + "--savetiddlers","[tag[external-image]]","images", + "--setfield","[tag[external-image]]","_canonical_uri","$:/core/templates/canonical-uri-external-image","text/plain", + "--setfield","[tag[external-image]]","text","","text/plain", + "--render","$:/core/save/all","index.html","text/plain"], + "favicon": [ + "--savetiddler","$:/favicon.ico","favicon.ico"], + "static": [ + "--render","$:/core/templates/static.template.html","static.html","text/plain", + "--render","$:/core/templates/alltiddlers.template.html","alltiddlers.html","text/plain", + "--render","[!is[system]]","[encodeuricomponent[]addprefix[static/]addsuffix[.html]]","text/plain","$:/core/templates/static.tiddler.html", + "--render","$:/core/templates/static.template.css","static/static.css","text/plain"] + }, + "config": { + "retain-original-tiddler-path": true + } +} diff --git a/editions/multiwikiserver/tiddlers/$__StoryList_1.tid b/editions/multiwikiserver/tiddlers/$__StoryList_1.tid new file mode 100644 index 00000000000..28aae8c87f6 --- /dev/null +++ b/editions/multiwikiserver/tiddlers/$__StoryList_1.tid @@ -0,0 +1,2 @@ +list: GettingStarted +title: $:/StoryList \ No newline at end of file diff --git a/editions/multiwikiserver/tiddlers/configMultiWikiServerEngine.tid b/editions/multiwikiserver/tiddlers/configMultiWikiServerEngine.tid new file mode 100644 index 00000000000..7a2d6abb031 --- /dev/null +++ b/editions/multiwikiserver/tiddlers/configMultiWikiServerEngine.tid @@ -0,0 +1,2 @@ +title: $:/config/MultiWikiServer/Engine +text: better diff --git a/editions/multiwikiserver/tiddlywiki.info b/editions/multiwikiserver/tiddlywiki.info new file mode 100644 index 00000000000..c963d59d1bf --- /dev/null +++ b/editions/multiwikiserver/tiddlywiki.info @@ -0,0 +1,39 @@ +{ + "description": "Multiple wiki client-server edition", + "plugins": [ + "tiddlywiki/tiddlyweb", + "tiddlywiki/filesystem", + "tiddlywiki/multiwikiclient", + "tiddlywiki/multiwikiserver" + ], + "themes": [ + "tiddlywiki/vanilla", + "tiddlywiki/snowwhite" + ], + "build": { + "load-mws-demo-data": [ + "--mws-load-wiki-folder","./editions/multiwikidocs","mws-docs", "MWS Documentation from https://mws.tiddlywiki.com","mws-docs","MWS Documentation from https://mws.tiddlywiki.com", + "--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", + "--mws-load-wiki-folder","./editions/tour","tour","TiddlyWiki Interactive Tour from https://tiddlywiki.com","tour", "TiddlyWiki Interactive Tour from https://tiddlywiki.com", + "--mws-create-bag","bag-alpha","A test bag", + "--mws-create-bag","bag-beta","Another test bag", + "--mws-create-bag","bag-gamma","A further test bag", + "--mws-create-recipe","recipe-rho","bag-alpha bag-beta","First wiki", + "--mws-create-recipe","recipe-sigma","bag-alpha bag-gamma","Second Wiki", + "--mws-create-recipe","recipe-tau","bag-alpha","Third Wiki", + "--mws-create-recipe","recipe-upsilon","bag-alpha bag-gamma bag-beta","Fourth Wiki", + "--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-add-permission", "READ", "Allows user to read recipes and bags", + "--mws-add-permission", "WRITE", "Gives the user the permission to edit and delete tiddlers", + "--mws-add-role", "ADMIN", "System Administrator", + "--mws-add-role", "USER", "Basic User", + "--mws-assign-role-permission", "ADMIN", "READ", + "--mws-assign-role-permission", "ADMIN", "WRITE", + "--mws-assign-role-permission", "USER", "READ" + ] + } +} \ No newline at end of file diff --git a/editions/test/playwright.spec.js b/editions/test/playwright.spec.js index 1d8c624c73a..526d7541a7b 100644 --- a/editions/test/playwright.spec.js +++ b/editions/test/playwright.spec.js @@ -12,9 +12,9 @@ test('get started link', async ({ page }) => { // Load the generated test TW html await page.goto(`file:///${crossPlatformIndexPath}`); - + const title = await page.title(); // Sanity check - await expect(page.locator('.tc-site-title'), "Expected correct page title to verify the test page was loaded").toHaveText('TiddlyWiki5'); + await expect(title, "Expected correct page title to verify the test page was loaded").toContain('TiddlyWiki5'); // Wait for jasmine results bar to appear await expect(page.locator('.jasmine-overall-result'), "Expected jasmine test results bar to be present").toBeVisible({timeout}); diff --git a/editions/test/tiddlywiki.info b/editions/test/tiddlywiki.info index 4a77f2a84eb..66445dd0323 100644 --- a/editions/test/tiddlywiki.info +++ b/editions/test/tiddlywiki.info @@ -2,6 +2,7 @@ "description": "TiddlyWiki core tests", "plugins": [ "tiddlywiki/jasmine", + "tiddlywiki/multiwikiserver", "tiddlywiki/geospatial" ], "themes": [ diff --git a/editions/tw5.com/tiddlers/readme/ReadMe.tid b/editions/tw5.com/tiddlers/readme/ReadMe.tid index 943cfa5c014..2c19cbda2db 100644 --- a/editions/tw5.com/tiddlers/readme/ReadMe.tid +++ b/editions/tw5.com/tiddlers/readme/ReadMe.tid @@ -6,7 +6,23 @@ type: text/vnd.tiddlywiki \define tv-wikilink-template() https://tiddlywiki.com/static/$uri_doubleencoded$.html \import [subfilter{$:/core/config/GlobalImportFilter}] -! Welcome +--- + +! ~TiddlyWiki ~MultiWikiServer + +UNDER DEVELOPMENT + +This is a branch of TiddlyWiki that adds the ~MultiWikiServer plugin. + +!! Readme + +{{$:/plugins/tiddlywiki/multiwikiserver/readme}} + +!! Docs + +{{$:/plugins/tiddlywiki/multiwikiserver/docs}} + +--- Welcome to TiddlyWiki, a non-linear personal web notebook that anyone can use and keep forever, independently of any corporation. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000000..a6a2620dc08 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1478 @@ +{ + "name": "tiddlywiki", + "version": "5.3.7-prerelease", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tiddlywiki", + "version": "5.3.7-prerelease", + "license": "BSD", + "dependencies": { + "better-sqlite3": "^11.5.0", + "node-sqlite3-wasm": "^0.8.25" + }, + "bin": { + "tiddlywiki": "tiddlywiki.js" + }, + "devDependencies": { + "@eslint/js": "^9.12.0", + "@playwright/test": "^1.47.2", + "eslint": "^9.12.0", + "playwright": "^1.47.2" + }, + "engines": { + "node": ">=0.8.2" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz", + "integrity": "sha512-pFoEtFWCPyDOl+C6Ift+wC7Ro89otjigCf5vcuWqWgqNSQbRrpjSvdeE6ofLz4dHmyxD5f7gIdGT4+p36L6Twg==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.2.tgz", + "integrity": "sha512-CXtq5nR4Su+2I47WPOlWud98Y5Lv8Kyxp2ukhgFx/eW6Blm18VXJO5WuQylPugRo8nbluoi6GvvxBLqHcvqUUw==", + "dev": true, + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", + "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@playwright/test": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.2.tgz", + "integrity": "sha512-54w1xCWfXuax7dz4W2M9uw0gDyh+ti/0K/MxcCUxChFh37kkdxPdfZDw5QBbuPUJHr1CiHJ1hXgSs+GgeQc5Zw==", + "dev": true, + "dependencies": { + "playwright": "1.48.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/better-sqlite3": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.5.0.tgz", + "integrity": "sha512-e/6eggfOutzoK0JWiU36jsisdWoHOfN9iWiW/SieKvb7SAa6aGNmBM/UKyp+/wWSXpLlWNN8tCPwoDNPhzUvuQ==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.14.0.tgz", + "integrity": "sha512-c2FHsVBr87lnUtjP4Yhvk4yEhKrQavGafRA/Se1ouse8PfbfC/Qh9Mxa00yWsZRlqeUB9raXip0aiiUZkgnr9g==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.14.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.0", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", + "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-abi": { + "version": "3.71.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.71.0.tgz", + "integrity": "sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-sqlite3-wasm": { + "version": "0.8.25", + "resolved": "https://registry.npmjs.org/node-sqlite3-wasm/-/node-sqlite3-wasm-0.8.25.tgz", + "integrity": "sha512-iqaFwW0aokbAWPJyy81xTm4vbQMUgWmGbcxVnn7xLOd5QZagtf5YIOHbXPpZv4AlC2yYw7cl0sM31Gmun8ojJA==" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/playwright": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.2.tgz", + "integrity": "sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==", + "dev": true, + "dependencies": { + "playwright-core": "1.48.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz", + "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", + "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index ef67ee1ceca..5ae2be339d7 100644 --- a/package.json +++ b/package.json @@ -24,18 +24,26 @@ "wiki" ], "devDependencies": { + "@playwright/test": "^1.47.2", "eslint": "^9.12.0", - "@eslint/js": "^9.12.0" + "@eslint/js": "^9.12.0", + "playwright": "^1.47.2" }, - "bundleDependencies": [], "license": "BSD", "engines": { "node": ">=0.8.2" }, "scripts": { - "dev": "node ./tiddlywiki.js ./editions/tw5.com-server --listen", - "test": "node ./tiddlywiki.js ./editions/test --verbose --version --build index", + "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": { + "better-sqlite3": "^11.5.0", + "node-sqlite3-wasm": "^0.8.25" } } diff --git a/plugins/tiddlywiki/multiwikiclient/GettingStarted.tid b/plugins/tiddlywiki/multiwikiclient/GettingStarted.tid new file mode 100644 index 00000000000..88ccac7a136 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/GettingStarted.tid @@ -0,0 +1,9 @@ +title: GettingStarted +tags: $:/tags/GettingStarted +caption: Step 1<br>Syncing + +! ~TiddlyWiki ~MultiWikiServer + +Welcome to ~TiddlyWiki and the ~TiddlyWiki community. + +Please note that ~MultiWikiServer is under active development, and may not be fully robust. Do not use it for anything critical. diff --git a/plugins/tiddlywiki/multiwikiclient/SaveWikiButtonTemplate.tid b/plugins/tiddlywiki/multiwikiclient/SaveWikiButtonTemplate.tid new file mode 100644 index 00000000000..45b8959ce01 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/SaveWikiButtonTemplate.tid @@ -0,0 +1,3 @@ +title: $:/config/SaveWikiButton/Template + +$:/plugins/tiddlywiki/multiwikiclient/save/offline \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiclient/config-incoming-updates-filter.tid b/plugins/tiddlywiki/multiwikiclient/config-incoming-updates-filter.tid new file mode 100644 index 00000000000..112f44c7262 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/config-incoming-updates-filter.tid @@ -0,0 +1,2 @@ +title: $:/config/multiwikiclient/incoming-updates-filter +text: [all[]] -[[$:/isEncrypted]] -[prefix[$:/temp/]] -[prefix[$:/status/]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/library/sjcl.js]] -[[$:/core]] -[prefix[$:/StoryList]] -[prefix[$:/HistoryList]] diff --git a/plugins/tiddlywiki/multiwikiclient/config-tiddlers-filter.tid b/plugins/tiddlywiki/multiwikiclient/config-tiddlers-filter.tid new file mode 100644 index 00000000000..4e5c951dbba --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/config-tiddlers-filter.tid @@ -0,0 +1,2 @@ +title: $:/config/Server/ExternalFilters/[all[tiddlers]] -[[$:/isEncrypted]] -[prefix[$:/temp/]] -[prefix[$:/status/]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/library/sjcl.js]] -[[$:/core]] +text: yes diff --git a/plugins/tiddlywiki/multiwikiclient/config-use-server-sent-events.tid b/plugins/tiddlywiki/multiwikiclient/config-use-server-sent-events.tid new file mode 100644 index 00000000000..43b6a2ca747 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/config-use-server-sent-events.tid @@ -0,0 +1,2 @@ +title: $:/config/multiwikiclient/use-server-sent-events +text: no diff --git a/plugins/tiddlywiki/multiwikiclient/configOfficialPluginLibrary.tid b/plugins/tiddlywiki/multiwikiclient/configOfficialPluginLibrary.tid new file mode 100644 index 00000000000..d2a07991ae1 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/configOfficialPluginLibrary.tid @@ -0,0 +1,7 @@ +title: $:/config/OfficialPluginLibrary +tags: $:/tags/PluginLibrary +url: https://tiddlywiki.com/library/v5.1.23/index.html +caption: {{$:/language/OfficialPluginLibrary}} +enabled: no + +The official plugin library is disabled when using the client-server configuration. Instead, plugins should be installed via the `tiddlywiki.info` file, as described [[here|https://tiddlywiki.com/#Installing%20a%20plugin%20from%20the%20plugin%20library]]. \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiclient/icon-cloud.tid b/plugins/tiddlywiki/multiwikiclient/icon-cloud.tid new file mode 100644 index 00000000000..e448bc54844 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/icon-cloud.tid @@ -0,0 +1,4 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/icon/cloud +tags: $:/tags/Image + +<svg class="tc-image-cloud tc-image-button" width="22pt" height="22pt" viewBox="0 0 128 128"><g><path d="M24 103C10.745 103 0 92.255 0 79c0-9.697 5.75-18.05 14.027-21.836A24.787 24.787 0 0114 56c0-13.255 10.745-24 24-24 1.373 0 2.718.115 4.028.337C48.628 24.2 58.707 19 70 19c19.882 0 36 16.118 36 36v.082c12.319 1.016 22 11.336 22 23.918 0 12.239-9.16 22.337-20.999 23.814L107 103H24z"/><path class="tc-image-cloud-idle" d="M57.929 84.698a6 6 0 01-8.485 0L35.302 70.556a6 6 0 118.485-8.485l9.9 9.9L81.97 43.686a6 6 0 018.485 8.486L57.929 84.698z"/><path class="tc-image-cloud-progress tc-animate-rotate-slow" d="M44.8 40a3.6 3.6 0 100 7.2h2.06A23.922 23.922 0 0040 64c0 13.122 10.531 23.785 23.603 23.997L64 88l.001-7.2c-9.171 0-16.626-7.348-16.798-16.477L47.2 64c0-5.165 2.331-9.786 5.999-12.868L53.2 55.6a3.6 3.6 0 107.2 0v-12a3.6 3.6 0 00-3.6-3.6h-12zM64 40v7.2c9.278 0 16.8 7.522 16.8 16.8 0 5.166-2.332 9.787-6 12.869V72.4a3.6 3.6 0 10-7.2 0v12a3.6 3.6 0 003.6 3.6h12a3.6 3.6 0 100-7.2l-2.062.001A23.922 23.922 0 0088 64c0-13.255-10.745-24-24-24z"/></g></svg> \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiclient/info-segment.tid b/plugins/tiddlywiki/multiwikiclient/info-segment.tid new file mode 100644 index 00000000000..950abc687a2 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/info-segment.tid @@ -0,0 +1,8 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/info-segment +tags: $:/tags/TiddlerInfoSegment + +<$reveal type="nomatch" state=<<folded-state>> text="hide" tag="div" retain="yes" animate="yes"> +<div class="tc-subtitle"> +Bag: <$view tiddler="$:/state/multiwikiclient/tiddlers/bag" index=<<currentTiddler>>>(none)</$view> +</div> +</$reveal> diff --git a/plugins/tiddlywiki/multiwikiclient/managetiddleraction.js b/plugins/tiddlywiki/multiwikiclient/managetiddleraction.js new file mode 100644 index 00000000000..b3d1c98ea37 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/managetiddleraction.js @@ -0,0 +1,65 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiclient/managetiddleraction.js +type: application/javascript +module-type: widget + +A widget to manage tiddler actions. +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +var Widget = require("$:/core/modules/widgets/widget.js").widget; + +var ManageTiddlerAction = function(parseTreeNode,options) { + this.initialise(parseTreeNode,options); +}; + +/* +Inherit from the base widget class +*/ +ManageTiddlerAction.prototype = new Widget(); + +/* +Render this widget into the DOM +*/ +ManageTiddlerAction.prototype.render = function(parent,nextSibling) { + this.computeAttributes(); + this.execute(); +}; + +/* +Compute the internal state of the widget +*/ +ManageTiddlerAction.prototype.execute = function() { + this.tiddler = this.getAttribute("tiddler"); +}; + +/* +Invoke the action associated with this widget +*/ +ManageTiddlerAction.prototype.invokeAction = function(triggeringWidget,event) { + var pathname = window.location.pathname; + var paths = pathname.split("/"); + var recipeName = paths[paths.length - 1]; + var bagName = document.querySelector("h1.tc-site-title").innerHTML; + window.location.href = "/admin/acl/"+recipeName+"/"+bagName; +}; + +/* +Refresh the widget by ensuring our attributes are up to date +*/ +ManageTiddlerAction.prototype.refresh = function(changedTiddlers) { + var changedAttributes = this.computeAttributes(); + if(changedAttributes.tiddler) { + this.refreshSelf(); + return true; + } + return this.refreshChildren(changedTiddlers); +}; + +exports["action-managetiddler"] = ManageTiddlerAction; + +})(); diff --git a/plugins/tiddlywiki/multiwikiclient/multiwikiclientadaptor.js b/plugins/tiddlywiki/multiwikiclient/multiwikiclientadaptor.js new file mode 100644 index 00000000000..39d0fe88215 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/multiwikiclientadaptor.js @@ -0,0 +1,399 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiclient/multiwikiclientadaptor.js +type: application/javascript +module-type: syncadaptor + +A sync adaptor module for synchronising with MultiWikiServer-compatible servers. It has three key areas of concern: + +* Basic operations like put, get, and delete a tiddler on the server +* Real time updates from the server (handled by SSE) +* Managing login/logout (not yet implemeneted) +* Bags and recipes, which are unknown to the syncer + +A key aspect of the design is that the syncer never overlaps basic server operations; it waits for the +previous operation to complete before sending a new one. + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +var CONFIG_HOST_TIDDLER = "$:/config/multiwikiclient/host", + DEFAULT_HOST_TIDDLER = "$protocol$//$host$/", + MWC_STATE_TIDDLER_PREFIX = "$:/state/multiwikiclient/", + BAG_STATE_TIDDLER = "$:/state/multiwikiclient/tiddlers/bag", + REVISION_STATE_TIDDLER = "$:/state/multiwikiclient/tiddlers/revision", + CONNECTION_STATE_TIDDLER = "$:/state/multiwikiclient/connection", + INCOMING_UPDATES_FILTER_TIDDLER = "$:/config/multiwikiclient/incoming-updates-filter", + ENABLE_SSE_TIDDLER = "$:/config/multiwikiclient/use-server-sent-events"; + +var SERVER_NOT_CONNECTED = "NOT CONNECTED", + SERVER_CONNECTING_SSE = "CONNECTING SSE", + SERVER_CONNECTED_SSE = "CONNECTED SSE", + SERVER_POLLING = "SERVER POLLING"; + +function MultiWikiClientAdaptor(options) { + this.wiki = options.wiki; + this.host = this.getHost(); + this.recipe = this.wiki.getTiddlerText("$:/config/multiwikiclient/recipe"); + this.useServerSentEvents = this.wiki.getTiddlerText(ENABLE_SSE_TIDDLER) === "yes"; + this.last_known_tiddler_id = $tw.utils.parseNumber(this.wiki.getTiddlerText("$:/state/multiwikiclient/recipe/last_tiddler_id","0")); + this.outstandingRequests = Object.create(null); // Hashmap by title of outstanding request object: {type: "PUT"|"GET"|"DELETE"} + this.lastRecordedUpdate = Object.create(null); // Hashmap by title of last recorded update via SSE: {type: "update"|"detetion", tiddler_id:} + this.logger = new $tw.utils.Logger("MultiWikiClientAdaptor"); + this.isLoggedIn = false; + this.isReadOnly = false; + this.logoutIsAvailable = true; + // Compile the dirty tiddler filter + this.incomingUpdatesFilterFn = this.wiki.compileFilter(this.wiki.getTiddlerText(INCOMING_UPDATES_FILTER_TIDDLER)); + this.setUpdateConnectionStatus(SERVER_NOT_CONNECTED); +} + +MultiWikiClientAdaptor.prototype.setUpdateConnectionStatus = function(status) { + this.serverUpdateConnectionStatus = status; + this.wiki.addTiddler({ + title: CONNECTION_STATE_TIDDLER, + text: status + }); +}; + +MultiWikiClientAdaptor.prototype.name = "multiwikiclient"; + +MultiWikiClientAdaptor.prototype.supportsLazyLoading = true; + +MultiWikiClientAdaptor.prototype.setLoggerSaveBuffer = function(loggerForSaving) { + this.logger.setSaveBuffer(loggerForSaving); +}; + +MultiWikiClientAdaptor.prototype.isReady = function() { + return true; +}; + +MultiWikiClientAdaptor.prototype.getHost = function() { + var text = this.wiki.getTiddlerText(CONFIG_HOST_TIDDLER,DEFAULT_HOST_TIDDLER), + substitutions = [ + {name: "protocol", value: document.location.protocol}, + {name: "host", value: document.location.host}, + {name: "pathname", value: document.location.pathname} + ]; + for(var t=0; t<substitutions.length; t++) { + var s = substitutions[t]; + text = $tw.utils.replaceString(text,new RegExp("\\$" + s.name + "\\$","mg"),s.value); + } + return text; +}; + +MultiWikiClientAdaptor.prototype.getTiddlerInfo = function(tiddler) { + var title = tiddler.fields.title, + revision = this.wiki.extractTiddlerDataItem(REVISION_STATE_TIDDLER,title), + bag = this.wiki.extractTiddlerDataItem(BAG_STATE_TIDDLER,title); + if(revision && bag) { + return { + title: title, + revision: revision, + bag: bag + }; + } else { + return undefined; + } +}; + +MultiWikiClientAdaptor.prototype.getTiddlerBag = function(title) { + return this.wiki.extractTiddlerDataItem(BAG_STATE_TIDDLER,title); +}; + +MultiWikiClientAdaptor.prototype.getTiddlerRevision = function(title) { + return this.wiki.extractTiddlerDataItem(REVISION_STATE_TIDDLER,title); +}; + +MultiWikiClientAdaptor.prototype.setTiddlerInfo = function(title,revision,bag) { + this.wiki.setText(BAG_STATE_TIDDLER,null,title,bag,{suppressTimestamp: true}); + this.wiki.setText(REVISION_STATE_TIDDLER,null,title,revision,{suppressTimestamp: true}); +}; + +MultiWikiClientAdaptor.prototype.removeTiddlerInfo = function(title) { + this.wiki.setText(BAG_STATE_TIDDLER,null,title,undefined,{suppressTimestamp: true}); + this.wiki.setText(REVISION_STATE_TIDDLER,null,title,undefined,{suppressTimestamp: true}); +}; + +/* +Get the current status of the server connection +*/ +MultiWikiClientAdaptor.prototype.getStatus = function(callback) { + // Invoke the callback if present + if(callback) { + callback( + null, // Error + true, // Is logged in + this.username, // Username + false, // Is read only + true // Is anonymous + ); + } +}; + +/* +Get details of changed tiddlers from the server +*/ +MultiWikiClientAdaptor.prototype.getUpdatedTiddlers = function(syncer,callback) { + if(this.useServerSentEvents) { + var self = this; + // Do nothing if there's already a connection in progress. + if(this.serverUpdateConnectionStatus !== SERVER_NOT_CONNECTED) { + return callback(null,{ + modifications: [], + deletions: [] + }); + } + // Try to connect a server stream + this.setUpdateConnectionStatus(SERVER_CONNECTING_SSE); + this.connectServerStream({ + syncer: syncer, + onerror: function(err) { + self.logger.log("Error connecting SSE stream",err); + // If the stream didn't work, try polling + self.setUpdateConnectionStatus(SERVER_POLLING); + self.pollServer({ + callback: function(err,changes) { + self.setUpdateConnectionStatus(SERVER_NOT_CONNECTED); + callback(null,changes); + } + }); + }, + onopen: function() { + self.setUpdateConnectionStatus(SERVER_CONNECTED_SSE); + // The syncer is expecting a callback but we don't have any data to send + callback(null,{ + modifications: [], + deletions: [] + }); + } + }); + } else { + this.pollServer({ + callback: function(err,changes) { + callback(null,changes); + } + }); + } +}; + +/* +Attempt to establish an SSE stream with the server and transfer tiddler changes. Options include: + +syncer: reference to syncer object used for storing data +onopen: invoked when the stream is successfully opened +onerror: invoked if there is an error +*/ +MultiWikiClientAdaptor.prototype.connectServerStream = function(options) { + var self = this; + const eventSource = new EventSource("/recipes/" + this.recipe + "/events?last_known_tiddler_id=" + this.last_known_tiddler_id); + eventSource.onerror = function(event) { + if(options.onerror) { + options.onerror(event); + } + } + eventSource.onopen = function(event) { + if(options.onopen) { + options.onopen(event); + } + } + eventSource.addEventListener("change", function(event) { + const data = $tw.utils.parseJSONSafe(event.data); + if(data) { + console.log("SSE data",data) + // Update last seen tiddler_id + if(data.tiddler_id > self.last_known_tiddler_id) { + self.last_known_tiddler_id = data.tiddler_id; + } + // Record the last update to this tiddler + self.lastRecordedUpdate[data.title] = { + type: data.is_deleted ? "deletion" : "update", + tiddler_id: data.tiddler_id + }; + console.log(`Oustanding requests is ${JSON.stringify(self.outstandingRequests[data.title])}`) + // Process the update if the tiddler is not the subject of an outstanding request + if(!self.outstandingRequests[data.title]) { + if(data.is_deleted) { + self.removeTiddlerInfo(data.title); + delete options.syncer.tiddlerInfo[data.title]; + options.syncer.logger.log("Deleting tiddler missing from server:",data.title); + options.syncer.wiki.deleteTiddler(data.title); + options.syncer.processTaskQueue(); + } else { + var result = self.incomingUpdatesFilterFn.call(self.wiki,self.wiki.makeTiddlerIterator([data.title])); + if(result.length > 0) { + self.setTiddlerInfo(data.title,data.tiddler_id.toString(),data.bag_name); + options.syncer.storeTiddler(data.tiddler); + } + } + } + } + }); +}; + +/* +Poll the server for changes. Options include: + +callback: invoked on completion as (err,changes) +*/ +MultiWikiClientAdaptor.prototype.pollServer = function(options) { + var self = this; + $tw.utils.httpRequest({ + url: this.host + "recipes/" + this.recipe + "/tiddlers.json", + data: { + last_known_tiddler_id: this.last_known_tiddler_id, + include_deleted: "true" + }, + callback: function(err,data) { + // Check for errors + if(err) { + return options.callback(err); + } + var modifications = [], + deletions = []; + var tiddlerInfoArray = $tw.utils.parseJSONSafe(data); + $tw.utils.each(tiddlerInfoArray,function(tiddlerInfo) { + if(tiddlerInfo.tiddler_id > self.last_known_tiddler_id) { + self.last_known_tiddler_id = tiddlerInfo.tiddler_id; + } + if(tiddlerInfo.is_deleted) { + deletions.push(tiddlerInfo.title); + } else { + modifications.push(tiddlerInfo.title); + } + }); + // Invoke the callback with the results + options.callback(null,{ + modifications: modifications, + deletions: deletions + }); + // If Browswer Storage tiddlers were cached on reloading the wiki, add them after sync from server completes in the above callback. + if($tw.browserStorage && $tw.browserStorage.isEnabled()) { + $tw.browserStorage.addCachedTiddlers(); + } + } + }); +}; + +/* +Queue a load for a tiddler if there has been an update for it since the specified revision +*/ +MultiWikiClientAdaptor.prototype.checkLastRecordedUpdate = function(title,revision,syncer) { + var lru = this.lastRecordedUpdate[title]; + if(lru) { + var numRevision = $tw.utils.getInt(revision); + console.log(`Checking for updates to ${title} since ${JSON.stringify(revision)} comparing to ${numRevision}`) + if(lru.tiddler_id > numRevision) { + options.syncer.enqueueLoadTiddler(title); + } + } +} + +/* +Save a tiddler and invoke the callback with (err,adaptorInfo,revision) +*/ +MultiWikiClientAdaptor.prototype.saveTiddler = function(tiddler,callback,options) { + var self = this, + title = tiddler.fields.title; + if(this.isReadOnly || title.substr(0,MWC_STATE_TIDDLER_PREFIX.length) === MWC_STATE_TIDDLER_PREFIX) { + return callback(null); + } + self.outstandingRequests[title] = {type: "PUT"}; + $tw.utils.httpRequest({ + url: this.host + "recipes/" + encodeURIComponent(this.recipe) + "/tiddlers/" + encodeURIComponent(title), + type: "PUT", + headers: { + "Content-type": "application/json" + }, + data: JSON.stringify(tiddler.getFieldStrings()), + callback: function(err,data,request) { + delete self.outstandingRequests[title]; + if(err) { + return callback(err); + } + //If Browser-Storage plugin is present, remove tiddler from local storage after successful sync to the server + if($tw.browserStorage && $tw.browserStorage.isEnabled()) { + $tw.browserStorage.removeTiddlerFromLocalStorage(title) + } + // Save the details of the new revision of the tiddler + var revision = request.getResponseHeader("X-Revision-Number"), + bag_name = request.getResponseHeader("X-Bag-Name"); +console.log(`Saved ${title} with revision ${revision} and bag ${bag_name}`) + // If there has been a more recent update from the server then enqueue a load of this tiddler + self.checkLastRecordedUpdate(title,revision,options.syncer); + // Invoke the callback + self.setTiddlerInfo(title,revision,bag_name); + callback(null,{bag: bag_name},revision); + } + }); +}; + +/* +Load a tiddler and invoke the callback with (err,tiddlerFields) +*/ +MultiWikiClientAdaptor.prototype.loadTiddler = function(title,callback,options) { + var self = this; + self.outstandingRequests[title] = {type: "GET"}; + $tw.utils.httpRequest({ + url: this.host + "recipes/" + encodeURIComponent(this.recipe) + "/tiddlers/" + encodeURIComponent(title), + callback: function(err,data,request) { + delete self.outstandingRequests[title]; + if(err === 404) { + return callback(null,null); + } else if(err) { + return callback(err); + } + var revision = request.getResponseHeader("X-Revision-Number"), + bag_name = request.getResponseHeader("X-Bag-Name"); + // If there has been a more recent update from the server then enqueue a load of this tiddler + self.checkLastRecordedUpdate(title,revision,options.syncer); + // Invoke the callback + self.setTiddlerInfo(title,revision,bag_name); + callback(null,$tw.utils.parseJSONSafe(data)); + } + }); +}; + +/* +Delete a tiddler and invoke the callback with (err) +options include: +tiddlerInfo: the syncer's tiddlerInfo for this tiddler +*/ +MultiWikiClientAdaptor.prototype.deleteTiddler = function(title,callback,options) { + var self = this; + if(this.isReadOnly) { + return callback(null); + } + // If we don't have a bag it means that the tiddler hasn't been seen by the server, so we don't need to delete it + var bag = this.getTiddlerBag(title); + if(!bag) { + return callback(null,options.tiddlerInfo.adaptorInfo); + } + self.outstandingRequests[title] = {type: "DELETE"}; + // Issue HTTP request to delete the tiddler + $tw.utils.httpRequest({ + url: this.host + "bags/" + encodeURIComponent(bag) + "/tiddlers/" + encodeURIComponent(title), + type: "DELETE", + callback: function(err,data,request) { + delete self.outstandingRequests[title]; + if(err) { + return callback(err); + } + var revision = request.getResponseHeader("X-Revision-Number"); + // If there has been a more recent update from the server then enqueue a load of this tiddler + self.checkLastRecordedUpdate(title,revision,options.syncer); + self.removeTiddlerInfo(title); + // Invoke the callback & return null adaptorInfo + callback(null,null); + } + }); +}; + +if($tw.browser && document.location.protocol.substr(0,4) === "http" ) { + exports.adaptorClass = MultiWikiClientAdaptor; +} + +})(); diff --git a/plugins/tiddlywiki/multiwikiclient/plugin.info b/plugins/tiddlywiki/multiwikiclient/plugin.info new file mode 100644 index 00000000000..6f94309d550 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/plugin.info @@ -0,0 +1,8 @@ +{ + "title": "$:/plugins/tiddlywiki/multiwikiclient", + "name": "MultiWikiClient", + "description": "Synchronise changes from the browser to TiddlyWiki ~MultiWikiServer", + "list": "readme", + "plugin-priority": 10, + "stability": "STABILITY_1_EXPERIMENTAL" +} diff --git a/plugins/tiddlywiki/multiwikiclient/readme.tid b/plugins/tiddlywiki/multiwikiclient/readme.tid new file mode 100644 index 00000000000..8fe63b1424a --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/readme.tid @@ -0,0 +1,8 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/readme + +This plugin runs in the browser to synchronise tiddler changes to and from a TiddlyWiki server running ~MultiWikiServer. + + +This plugin is inert when run under Node.js. Disabling this plugin via the browser can not be undone via the browser since this plugin provides the mechanism to synchronize settings with the server. + +Changes made while offline are saved in memory and automatically synchonised with the server when the connection is re-established. However, if the browser tab is closed or another URL is loaded, the in-memory changes will be lost. The [[https://tiddlywiki.com/#BrowserStorage Plugin]] may be added to provide temporary filesystem storage of tiddler changes made while offline and enable them to be synchronised with the server the next time the wiki is loaded in the same browser. diff --git a/plugins/tiddlywiki/multiwikiclient/readonly-styles.tid b/plugins/tiddlywiki/multiwikiclient/readonly-styles.tid new file mode 100644 index 00000000000..4269ba799eb --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/readonly-styles.tid @@ -0,0 +1,27 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/readonly +tags: [[$:/tags/Stylesheet]] + +\define button-selector(title) +button.$title$, .tc-drop-down button.$title$, div.$title$ +\end + +\define hide-edit-controls() +<$reveal state="$:/status/IsReadOnly" type="match" text="yes" default="yes"> +<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fclone>>`,` +<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fdelete>>`,` +<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fedit>>`,` +<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-here>>`,` +<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-journal-here>>`,` +<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fimport>>`,` +<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fmanager>>`,` +<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-image>>`,` +<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-journal>>`,` +<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-tiddler>> `{ + display: none; +}` +</$reveal> +\end + +\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock + +<<hide-edit-controls>> diff --git a/plugins/tiddlywiki/multiwikiclient/save-offline.tid b/plugins/tiddlywiki/multiwikiclient/save-offline.tid new file mode 100644 index 00000000000..20c420cd838 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/save-offline.tid @@ -0,0 +1,7 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/save/offline + +\import [subfilter{$:/core/config/GlobalImportFilter}] +\define saveTiddlerFilter() +[is[tiddler]] -[[$:/boot/boot.css]] -[prefix[$:/HistoryList]] -[status[pending]plugin-type[import]] -[type[application/javascript]library[yes]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/plugins/tiddlywiki/filesystem]] -[[$:/plugins/tiddlywiki/multiwikiclient]] -[prefix[$:/temp/]] +[sort[title]] $(publishFilter)$ +\end +{{$:/core/templates/tiddlywiki5.html}} diff --git a/plugins/tiddlywiki/multiwikiclient/save-wiki-button.tid b/plugins/tiddlywiki/multiwikiclient/save-wiki-button.tid new file mode 100644 index 00000000000..07e72d33585 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/save-wiki-button.tid @@ -0,0 +1,26 @@ +title: $:/core/ui/Buttons/save-wiki +tags: $:/tags/PageControls +caption: {{$:/plugins/tiddlywiki/multiwikiclient/icon/cloud}} Server status +description: Status of synchronisation with server + +\whitespace trim +\define config-title() +$:/config/PageControlButtons/Visibility/$(listItem)$ +\end +<$button popup=<<qualify "$:/state/popup/save-wiki">> tooltip="Status of synchronisation with server" aria-label="Server status" class=<<tv-config-toolbar-class>> selectedClass="tc-selected"> +<span class="tc-dirty-indicator"> +<$list filter="[<tv-config-toolbar-icons>match[yes]]"> +{{$:/plugins/tiddlywiki/multiwikiclient/icon/cloud}} +</$list> +<$list filter="[<tv-config-toolbar-text>match[yes]]"> +<span class="tc-btn-text"><$text text="Server status"/></span> +</$list> +</span> +</$button> +<$reveal state=<<qualify "$:/state/popup/save-wiki">> type="popup" position="belowleft" animate="yes"> +<div class="tc-drop-down"> +<$list filter="[all[shadows+tiddlers]tag[$:/tags/SyncerDropdown]!has[draft.of]]" variable="listItem"> +<$transclude tiddler=<<listItem>>/> +</$list> +</div> +</$reveal> diff --git a/plugins/tiddlywiki/multiwikiclient/sidebarsegment.tid b/plugins/tiddlywiki/multiwikiclient/sidebarsegment.tid new file mode 100644 index 00000000000..00b0671f721 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/sidebarsegment.tid @@ -0,0 +1,9 @@ +title: $:/plugins/multiwikiclient/SideBarSegment +tags: $:/tags/SideBarSegment +list-before: $:/core/ui/SideBarSegments/page-controls + +<%if [{$:/config/multiwikiclient/use-server-sent-events}match[yes]] %> + +MWS Connection Status: {{$:/state/multiwikiclient/connection}} + +<%endif%> diff --git a/plugins/tiddlywiki/multiwikiclient/styles.tid b/plugins/tiddlywiki/multiwikiclient/styles.tid new file mode 100644 index 00000000000..8fba4ec14c3 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/styles.tid @@ -0,0 +1,44 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/styles +tags: [[$:/tags/Stylesheet]] + +\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock + +body.tc-dirty span.tc-dirty-indicator svg { + transition: fill 250ms ease-in-out; +} + +body .tc-image-cloud-idle { + fill: <<colour background>>; + transition: opacity 250ms ease-in-out; + opacity: 1; + display: unset; +} + +body.tc-dirty .tc-image-cloud-idle { + opacity: 0; + display: none; +} + +body .tc-image-cloud-progress { + transition: opacity 250ms ease-in-out; + transform-origin: 50% 50%; + transform: rotate(359deg); + animation: animation-rotate-slow 2s infinite linear; + fill: <<colour background>>; + display: none; + opacity: 0; +} + +body.tc-dirty .tc-image-cloud-progress { + opacity: 1; + display: unset; +} + +@keyframes animation-rotate-slow { + from { + transform: rotate(0deg); + } + to { + transform: scale(359deg); + } +} diff --git a/plugins/tiddlywiki/multiwikiclient/syncer-actions-copy-logs.tid b/plugins/tiddlywiki/multiwikiclient/syncer-actions-copy-logs.tid new file mode 100644 index 00000000000..4457f7529ab --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/syncer-actions-copy-logs.tid @@ -0,0 +1,6 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/copy-logs +tags: $:/tags/SyncerDropdown + +<$button message="tm-copy-syncer-logs-to-clipboard" class="tc-btn-invisible"> +{{$:/core/images/copy-clipboard}} Copy syncer logs to clipboard +</$button> diff --git a/plugins/tiddlywiki/multiwikiclient/syncer-actions-login-status.tid b/plugins/tiddlywiki/multiwikiclient/syncer-actions-login-status.tid new file mode 100644 index 00000000000..6ebf98fcd03 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/syncer-actions-login-status.tid @@ -0,0 +1,9 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login-status +tags: $:/tags/SyncerDropdown + +<$reveal state="$:/status/IsLoggedIn" type="match" text="yes"> +<div class="tc-drop-down-info"> +You are logged in<$reveal state="$:/status/UserName" type="nomatch" text="" default=""> as <strong><$text text={{$:/status/UserName}}/></strong></$reveal><$reveal state="$:/status/IsReadOnly" type="match" text="yes" default="no"> (read-only)</$reveal> +</div> +<hr/> +</$reveal> diff --git a/plugins/tiddlywiki/multiwikiclient/syncer-actions-login.tid b/plugins/tiddlywiki/multiwikiclient/syncer-actions-login.tid new file mode 100644 index 00000000000..02cd2b6f686 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/syncer-actions-login.tid @@ -0,0 +1,8 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login +tags: $:/tags/SyncerDropdown + +<$reveal state="$:/status/IsLoggedIn" type="nomatch" text="yes"> +<$button message="tm-login" class="tc-btn-invisible"> +{{$:/core/images/unlocked-padlock}} Login +</$button> +</$reveal> diff --git a/plugins/tiddlywiki/multiwikiclient/syncer-actions-logout.tid b/plugins/tiddlywiki/multiwikiclient/syncer-actions-logout.tid new file mode 100644 index 00000000000..cab5f261f4f --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/syncer-actions-logout.tid @@ -0,0 +1,8 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/logout +tags: $:/tags/SyncerDropdown + +<$reveal state="$:/status/IsLoggedIn" type="match" text="yes"> +<$button message="tm-logout" class="tc-btn-invisible"> +{{$:/core/images/cancel-button}} Logout +</$button> +</$reveal> diff --git a/plugins/tiddlywiki/multiwikiclient/syncer-actions-refresh.tid b/plugins/tiddlywiki/multiwikiclient/syncer-actions-refresh.tid new file mode 100644 index 00000000000..ea95a67d6fb --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/syncer-actions-refresh.tid @@ -0,0 +1,9 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/refresh +tags: $:/tags/SyncerDropdown + +<$reveal state="$:/status/IsLoggedIn" type="match" text="yes"> +<$button tooltip="Get latest changes from the server" aria-label="Refresh from server" class="tc-btn-invisible"> +<$action-sendmessage $message="tm-server-refresh"/> +{{$:/core/images/refresh-button}}<span class="tc-btn-text"><$text text="Get latest changes from the server"/></span> +</$button> +</$reveal> diff --git a/plugins/tiddlywiki/multiwikiclient/syncer-actions-save-snapshot.tid b/plugins/tiddlywiki/multiwikiclient/syncer-actions-save-snapshot.tid new file mode 100644 index 00000000000..db9b16e1507 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/syncer-actions-save-snapshot.tid @@ -0,0 +1,9 @@ +title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/save-snapshot +tags: $:/tags/SyncerDropdown + +<$button class="tc-btn-invisible"> +<$wikify name="site-title" text={{$:/config/SaveWikiButton/Filename}}> +<$action-sendmessage $message="tm-download-file" $param={{$:/config/SaveWikiButton/Template}} filename=<<site-title>>/> +</$wikify> +{{$:/core/images/download-button}} Save snapshot for offline use +</$button> diff --git a/plugins/tiddlywiki/multiwikiclient/tags-syncerdropdown.tid b/plugins/tiddlywiki/multiwikiclient/tags-syncerdropdown.tid new file mode 100644 index 00000000000..4b5774eb9d4 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiclient/tags-syncerdropdown.tid @@ -0,0 +1,2 @@ +title: $:/tags/SyncerDropdown +list: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login-status $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/refresh $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/logout $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/save-snapshot $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/copy-logs diff --git a/plugins/tiddlywiki/multiwikiserver/auth/authentication.js b/plugins/tiddlywiki/multiwikiserver/auth/authentication.js new file mode 100644 index 00000000000..0658c93beb0 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/authentication.js @@ -0,0 +1,43 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/authentication.js +type: application/javascript +module-type: library + +Handles authentication related operations + +\*/ + +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +var crypto = require("crypto"); + +function Authenticator(database) { + if(!(this instanceof Authenticator)) { + return new Authenticator(database); + } + this.sqlTiddlerDatabase = database; +} + +Authenticator.prototype.verifyPassword = function(inputPassword, storedHash) { + var hashedInput = this.hashPassword(inputPassword); + return hashedInput === storedHash; +}; + +Authenticator.prototype.hashPassword = function(password) { + return crypto.createHash("sha256").update(password).digest("hex"); +}; + +Authenticator.prototype.createSession = function(userId) { + var sessionId = crypto.randomBytes(16).toString("hex"); + // Store the session in your database or in-memory store + this.sqlTiddlerDatabase.createUserSession(userId, sessionId); + return sessionId; +}; + +exports.Authenticator = Authenticator; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login.tid new file mode 100644 index 00000000000..0cb03657a3a --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login.tid @@ -0,0 +1,19 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login +tags: $:/tags/ServerRoute +route-method: GET +route-path: /login + +<$transclude tiddler="$:/plugins/tiddlywiki/multiwikiserver/auth/form/login/styles"/> + +<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> \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login/error-message.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login/error-message.tid new file mode 100644 index 00000000000..53b663ae66c --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login/error-message.tid @@ -0,0 +1,7 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/error-message + +<$list filter="[[$:/temp/mws/login/error]!is[missing]]" variable="errorTiddler"> + <div class="tc-error-message"> + {{$:/temp/mws/login/error}} + </div> +</$list> \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login/form.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login/form.tid new file mode 100644 index 00000000000..2dcd1e89da6 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login/form.tid @@ -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> diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login/head.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login/head.tid new file mode 100644 index 00000000000..b5df2aa5a4b --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login/head.tid @@ -0,0 +1,3 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/head + +<title>TiddlyWiki Login \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login/header.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login/header.tid new file mode 100644 index 00000000000..caa5cbe72b1 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login/header.tid @@ -0,0 +1,41 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/header + + + + \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/auth/form/login/styles.tid b/plugins/tiddlywiki/multiwikiserver/auth/form/login/styles.tid new file mode 100644 index 00000000000..c4905227cc4 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/auth/form/login/styles.tid @@ -0,0 +1,48 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/auth/form/login/styles + + \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerAllowAnonymousReads.tid b/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerAllowAnonymousReads.tid new file mode 100644 index 00000000000..447356619f3 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerAllowAnonymousReads.tid @@ -0,0 +1,4 @@ +title: $:/config/MultiWikiServer/AllowAnonymousReads +text: no +description: Controls whether anonymous users can read wiki content +type: text/plain diff --git a/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerAllowAnonymousWrites.tid b/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerAllowAnonymousWrites.tid new file mode 100644 index 00000000000..e9f340e1fd7 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerAllowAnonymousWrites.tid @@ -0,0 +1,4 @@ +title: $:/config/MultiWikiServer/AllowAnonymousWrites +text: no +description: Controls whether anonymous users can write to the wiki +type: text/plain diff --git a/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerAttachmentSizeLimit.tid b/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerAttachmentSizeLimit.tid new file mode 100644 index 00000000000..e3396c6b837 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerAttachmentSizeLimit.tid @@ -0,0 +1,2 @@ +title: $:/config/MultiWikiServer/AttachmentSizeLimit +text: 204800 diff --git a/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerEnableAttachments.tid b/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerEnableAttachments.tid new file mode 100644 index 00000000000..cb407955292 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerEnableAttachments.tid @@ -0,0 +1,2 @@ +title: $:/config/MultiWikiServer/EnableAttachments +text: yes diff --git a/plugins/tiddlywiki/multiwikiserver/docs/readme.tid b/plugins/tiddlywiki/multiwikiserver/docs/readme.tid new file mode 100644 index 00000000000..b61c6683db0 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/docs/readme.tid @@ -0,0 +1,5 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/readme + +This plugin extends the TiddlyWiki 5 server running on Node.js to be able to host multiple wikis that can share content or be independent. + +See https://mws.tiddlywiki.com/ for more information. diff --git a/plugins/tiddlywiki/multiwikiserver/icon.tid b/plugins/tiddlywiki/multiwikiserver/icon.tid new file mode 100644 index 00000000000..498ffc89593 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/icon.tid @@ -0,0 +1,4 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/icon +type: image/svg+xml + + diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-permission.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-permission.js new file mode 100644 index 00000000000..fe742956612 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-permission.js @@ -0,0 +1,47 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-add-permission.js +type: application/javascript +module-type: command + +Command to create a permission + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-add-permission", + synchronous: false +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + + if(this.params.length < 2) { + return "Usage: --mws-add-permission "; + } + + if(!$tw.mws || !$tw.mws.store || !$tw.mws.store.sqlTiddlerDatabase) { + return "Error: MultiWikiServer or SQL database not initialized."; + } + + var permission_name = this.params[0]; + var description = this.params[1]; + + $tw.mws.store.sqlTiddlerDatabase.createPermission(permission_name, description); + self.callback(); + return null; +}; + +exports.Command = Command; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-role.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-role.js new file mode 100644 index 00000000000..ec435a97ff3 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-role.js @@ -0,0 +1,47 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-add-role.js +type: application/javascript +module-type: command + +Command to create a role + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-add-role", + synchronous: false +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + + if(this.params.length < 2) { + return "Usage: --mws-add-role "; + } + + if(!$tw.mws || !$tw.mws.store || !$tw.mws.store.sqlTiddlerDatabase) { + return "Error: MultiWikiServer or SQL database not initialized."; + } + + var role_name = this.params[0]; + var description = this.params[1]; + + $tw.mws.store.sqlTiddlerDatabase.createRole(role_name, description); + self.callback(null, "Role Created Successfully!"); + return null; +}; + +exports.Command = Command; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-user.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-user.js new file mode 100644 index 00000000000..fc0c4e6e197 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-add-user.js @@ -0,0 +1,56 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-add-user.js +type: application/javascript +module-type: command + +Command to create users and grant permission + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; +if($tw.node) { + var crypto = require("crypto"); +} +exports.info = { + name: "mws-add-user", + synchronous: false +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + + if(this.params.length < 2) { + return "Usage: --mws-add-user [email]"; + } + + if(!$tw.mws || !$tw.mws.store || !$tw.mws.store.sqlTiddlerDatabase) { + return "Error: MultiWikiServer or SQL database not initialized."; + } + + var username = this.params[0]; + var password = this.params[1]; + var email = this.params[2] || username + "@example.com"; + var hashedPassword = crypto.createHash("sha256").update(password).digest("hex"); + + var user = $tw.mws.store.sqlTiddlerDatabase.getUserByUsername(username); + + if(!user) { + $tw.mws.store.sqlTiddlerDatabase.createUser(username, email, hashedPassword); + console.log("User Account Created Successfully with username: " + username + " and password: " + password); + self.callback(); + } + return null; +}; + +exports.Command = Command; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-role-permission.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-role-permission.js new file mode 100644 index 00000000000..89ed568d932 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-role-permission.js @@ -0,0 +1,60 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-assign-role-permission.js +type: application/javascript +module-type: command + +Command to assign permission to a role + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-assign-role-permission", + synchronous: false +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + + if(this.params.length < 2) { + return "Usage: --mws-assign-role-permission "; + } + + if(!$tw.mws || !$tw.mws.store || !$tw.mws.store.sqlTiddlerDatabase) { + return "Error: MultiWikiServer or SQL database not initialized."; + } + + var role_name = this.params[0]; + var permission_name = this.params[1]; + var role = $tw.mws.store.sqlTiddlerDatabase.getRoleByName(role_name); + var permission = $tw.mws.store.sqlTiddlerDatabase.getPermissionByName(permission_name); + + if(!role) { + return "Error: Unable to find Role: "+role_name; + } + + if(!permission) { + return "Error: Unable to find Permission: "+permission_name; + } + + var permission = $tw.mws.store.sqlTiddlerDatabase.getPermissionByName(permission_name); + + + $tw.mws.store.sqlTiddlerDatabase.addPermissionToRole(role.role_id, permission.permission_id); + self.callback(); + return null; +}; + +exports.Command = Command; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-user-role.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-user-role.js new file mode 100644 index 00000000000..2657dbdd332 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-assign-user-role.js @@ -0,0 +1,59 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-assign-user-role.js +type: application/javascript +module-type: command + +Command to assign a role to a user + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-assign-user-role", + synchronous: false +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + + if(this.params.length < 2) { + return "Usage: --mws-assign-user-role "; + } + + if(!$tw.mws || !$tw.mws.store || !$tw.mws.store.sqlTiddlerDatabase) { + return "Error: MultiWikiServer or SQL database not initialized."; + } + + var username = this.params[0]; + var role_name = this.params[1]; + var role = $tw.mws.store.sqlTiddlerDatabase.getRoleByName(role_name); + var user = $tw.mws.store.sqlTiddlerDatabase.getUserByUsername(username); + + if(!role) { + return "Error: Unable to find Role: "+role_name; + } + + if(!user) { + return "Error: Unable to find user with the username "+username; + } + + $tw.mws.store.sqlTiddlerDatabase.addRoleToUser(user.user_id, role.role_id); + + console.log(role_name+" role has been assigned to user with username "+username) + self.callback(); + return null; +}; + +exports.Command = Command; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-create-bag.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-create-bag.js new file mode 100644 index 00000000000..c90088a8729 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-create-bag.js @@ -0,0 +1,47 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-create-bag.js +type: application/javascript +module-type: command + +Command to load archive of recipes, bags and tiddlers from a directory + +--mws-create-bag + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-create-bag", + synchronous: true +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + // Check parameters + if(this.params.length < 1) { + return "Missing bag name"; + } + var bagName = this.params[0], + bagDescription = this.params[1] || bagName; + // Create bag + var result = $tw.mws.store.createBag(bagName,bagDescription); + if(result) { + return result.message; + } else { + return null; + } +}; + +exports.Command = Command; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-create-recipe.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-create-recipe.js new file mode 100644 index 00000000000..6515c817a20 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-create-recipe.js @@ -0,0 +1,50 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-create-recipe.js +type: application/javascript +module-type: command + +Command to load archive of recipes, bags and tiddlers from a directory + +--mws-create-recipe + +The parameter "bag-list" should be a space delimited list of bags + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-create-recipe", + synchronous: true +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + // Check parameters + if(this.params.length < 1) { + return "Missing recipe name"; + } + var recipeName = this.params[0], + bagList = (this.params[1] || "").split(" "), + recipeDescription = this.params[2] || recipeNameName; + // Create recipe + var result = $tw.mws.store.createRecipe(recipeName,bagList,recipeDescription); + if(result) { + return result.message; + } else { + return null; + } +}; + +exports.Command = Command; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-listen.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-listen.js new file mode 100644 index 00000000000..0ee10f9be2c --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-listen.js @@ -0,0 +1,49 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-listen.js +type: application/javascript +module-type: command + +Listen for HTTP requests and serve tiddlers + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-listen", + synchronous: false, + namedParameterMode: true, + mandatoryParameters: [] +}; + +var Command = function(params,commander,callback) { + var self = this; + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + if(!$tw.boot.wikiTiddlersPath) { + $tw.utils.warning("Warning: Wiki folder '" + $tw.boot.wikiPath + "' does not exist or is missing a tiddlywiki.info file"); + } + // Set up server + this.server = $tw.mws.serverManager.createServer({ + wiki: $tw.wiki, + variables: self.params + }); + this.server.listen(null,null,null,{ + callback: function() { + self.callback(); + } + }); + return null; +}; + +exports.Command = Command; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-load-archive.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-load-archive.js new file mode 100644 index 00000000000..9e37cb32b08 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-load-archive.js @@ -0,0 +1,93 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-load-archive.js +type: application/javascript +module-type: command + +Command to load archive of recipes, bags and tiddlers from a directory + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-load-archive", + synchronous: true +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + // Check parameters + if(this.params.length < 1) { + return "Missing pathname"; + } + var archivePath = this.params[0]; + loadBackupArchive(archivePath); + return null; +}; + +function loadBackupArchive(archivePath) { + const fs = require("fs"), + path = require("path"); + // Iterate the bags + const bagNames = fs.readdirSync(path.resolve(archivePath,"bags")).filter(filename => filename !== ".DS_Store"); + for(const bagFilename of bagNames) { + const bagName = decodeURIComponent(bagFilename); + console.log(`Reading bag ${bagName}`); + const bagInfo = JSON.parse(fs.readFileSync(path.resolve(archivePath,"bags",bagFilename,"meta.json"),"utf8")); + $tw.mws.store.createBag(bagName,bagInfo.description,bagInfo.accesscontrol); + if(fs.existsSync(path.resolve(archivePath,"bags",bagFilename,"tiddlers"))) { + const tiddlerFilenames = fs.readdirSync(path.resolve(archivePath,"bags",bagFilename,"tiddlers")); + for(const tiddlerFilename of tiddlerFilenames) { + if(tiddlerFilename.endsWith(".json")) { + const tiddlerPath = path.resolve(archivePath,"bags",bagFilename,"tiddlers",tiddlerFilename), + jsonTiddler = fs.readFileSync(tiddlerPath,"utf8"), + tiddler = sanitiseTiddler(JSON.parse(jsonTiddler)); + if(tiddler && tiddler.title) { + $tw.mws.store.saveBagTiddler(tiddler,bagName); + } else { + console.log(`Malformed JSON tiddler in file ${tiddlerPath}`); + } + } + } + } + } + // Iterate the recipes + const recipeNames = fs.readdirSync(path.resolve(archivePath,"recipes")); + for(const recipeFilename of recipeNames) { + if(recipeFilename.endsWith(".json")) { + const recipeName = decodeURIComponent(recipeFilename.substring(0,recipeFilename.length - ".json".length)); + const jsonInfo = JSON.parse(fs.readFileSync(path.resolve(archivePath,"recipes",recipeFilename),"utf8")); + $tw.mws.store.createRecipe(recipeName,jsonInfo.bag_names,jsonInfo.description,jsonInfo.accesscontrol); + } + } +}; + +function sanitiseTiddler(tiddler) { + var sanitisedFields = Object.create(null); + for(const fieldName in tiddler) { + const fieldValue = tiddler[fieldName]; + let sanitisedValue = ""; + if(typeof fieldValue === "string") { + sanitisedValue = fieldValue; + } else if($tw.utils.isDate(fieldValue)) { + sanitisedValue = $tw.utils.stringifyDate(fieldValue); + } else if($tw.utils.isArray(fieldValue)) { + sanitisedValue = $tw.utils.stringifyList(fieldValue); + } + sanitisedFields[fieldName] = sanitisedValue; + } + return sanitisedFields; +} + +exports.Command = Command; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-load-plugin-bags.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-load-plugin-bags.js new file mode 100644 index 00000000000..40bfb37493d --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-load-plugin-bags.js @@ -0,0 +1,81 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-load-plugin-bags.js +type: application/javascript +module-type: command + +Command to create and load a bag for each plugin in the repo + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-load-plugin-bags", + synchronous: true +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + loadPluginBags(); + return null; +}; + +function loadPluginBags() { + const path = require("path"), + fs = require("fs"); + // Copy plugins + var makePluginBagName = function(type,publisher,name) { + return "$:/" + type + "/" + (publisher ? publisher + "/" : "") + name; + }, + savePlugin = function(pluginFields,type,publisher,name) { + const bagName = makePluginBagName(type,publisher,name); + const result = $tw.mws.store.createBag(bagName,pluginFields.description || "(no description)",{allowPrivilegedCharacters: true}); + if(result) { + console.log(`Error creating plugin bag ${bagname}: ${JSON.stringify(result)}`); + } + $tw.mws.store.saveBagTiddler(pluginFields,bagName); + }, + collectPlugins = function(folder,type,publisher) { + var pluginFolders = $tw.utils.getSubdirectories(folder) || []; + for(var p=0; p + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-load-wiki-folder", + synchronous: true +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + // Check parameters + if(this.params.length < 5) { + return "Missing parameters for --mws-load-wiki-folder command"; + } + var archivePath = this.params[0]; + loadWikiFolder({ + wikiPath: this.params[0], + bagName: this.params[1], + bagDescription: this.params[2], + recipeName: this.params[3], + recipeDescription: this.params[4] + }); + return null; +}; + +// Function to convert a plugin name to a bag name +function makePluginBagName(type,publisher,name) { + return "$:/" + type + "/" + (publisher ? publisher + "/" : "") + name; +} + +// Copy TiddlyWiki core editions +function loadWikiFolder(options) { + const path = require("path"), + fs = require("fs"); + // Read the tiddlywiki.info file + const wikiInfoPath = path.resolve(options.wikiPath,$tw.config.wikiInfo); + let wikiInfo; + if(fs.existsSync(wikiInfoPath)) { + wikiInfo = $tw.utils.parseJSONSafe(fs.readFileSync(wikiInfoPath,"utf8"),function() {return null;}); + } + if(wikiInfo) { + // Create the bag + const result = $tw.mws.store.createBag(options.bagName,options.bagDescription); + if(result) { + console.log(`Error creating bag ${options.bagName} for edition ${options.wikiPath}: ${JSON.stringify(result)}`); + } + // Add plugins to the recipe list + const recipeList = []; + const processPlugins = function(type,plugins) { + $tw.utils.each(plugins,function(pluginName) { + const parts = pluginName.split("/"); + let publisher, name; + if(parts.length === 2) { + publisher = parts[0]; + name = parts[1]; + } else { + name = parts[0]; + } + recipeList.push(makePluginBagName(type,publisher,name)); + }); + }; + processPlugins("plugins",wikiInfo.plugins); + processPlugins("themes",wikiInfo.themes); + processPlugins("languages",wikiInfo.languages); + // Create the recipe + recipeList.push(options.bagName); + $tw.mws.store.createRecipe(options.recipeName,recipeList,options.recipeDescription); + $tw.mws.store.saveTiddlersFromPath(path.resolve(options.wikiPath,$tw.config.wikiTiddlersSubDir),options.bagName); + } +} + +exports.Command = Command; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-save-archive.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-save-archive.js new file mode 100644 index 00000000000..d8b67753ca7 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-save-archive.js @@ -0,0 +1,62 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-save-archive.js +type: application/javascript +module-type: command + +Command to load an archive of recipes, bags and tiddlers to a directory + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-save-archive", + synchronous: true +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + // Check parameters + if(this.params.length < 1) { + return "Missing pathname"; + } + var archivePath = this.params[0]; + saveArchive(archivePath); + return null; +}; + +function saveArchive(archivePath) { + const fs = require("fs"), + path = require("path"); + function saveJsonFile(filename,json) { + const filepath = path.resolve(archivePath,filename); + console.log(filepath); + $tw.utils.createFileDirectories(filepath); + fs.writeFileSync(filepath,JSON.stringify(json,null,4)); + } + for(const recipeInfo of $tw.mws.store.listRecipes()) { + console.log(`Recipe ${recipeInfo.recipe_name}`); + saveJsonFile(`recipes/${$tw.utils.encodeURIComponentExtended(recipeInfo.recipe_name)}.json`,recipeInfo); + } + for(const bagInfo of $tw.mws.store.listBags()) { + console.log(`Bag ${bagInfo.bag_name}`); + saveJsonFile(`bags/${$tw.utils.encodeURIComponentExtended(bagInfo.bag_name)}/meta.json`,bagInfo); + for(const title of $tw.mws.store.getBagTiddlers(bagInfo.bag_name)) { + const tiddlerInfo = $tw.mws.store.getBagTiddler(title,bagInfo.bag_name); + saveJsonFile(`bags/${$tw.utils.encodeURIComponentExtended(bagInfo.bag_name)}/tiddlers/${$tw.utils.encodeURIComponentExtended(title)}.json`,tiddlerInfo.tiddler); + } + } +} + +exports.Command = Command; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-save-tiddler-text.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-save-tiddler-text.js new file mode 100644 index 00000000000..524ec948a82 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-save-tiddler-text.js @@ -0,0 +1,44 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-save-tiddler-text.js +type: application/javascript +module-type: command + +Command to load archive of recipes, bags and tiddlers from a directory + +--mws-save-tiddler-text + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-save-tiddler-text", + synchronous: true +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + // Check parameters + if(this.params.length < 3) { + return "Missing parameters"; + } + var bagName = this.params[0], + tiddlerTitle = this.params[1], + tiddlerText = this.params[2]; + // Save tiddler + $tw.mws.store.saveBagTiddler({title: tiddlerTitle,text: tiddlerText},bagName); + return null; +}; + +exports.Command = Command; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-test-server.js b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-test-server.js new file mode 100644 index 00000000000..5d03844aa7e --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/commands/mws-test-server.js @@ -0,0 +1,165 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-test-server.js +type: application/javascript +module-type: command + +Command to test a local or remote MWS server + +tiddlywiki editions/multiwikiserver/ --listen --mws-test-server http://127.0.0.1:8080/ + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.info = { + name: "mws-test-server", + synchronous: false +}; + +var Command = function(params,commander,callback) { + this.params = params; + this.commander = commander; + this.callback = callback; +}; + +Command.prototype.execute = function() { + var self = this; + // Check parameters + if(this.params.length < 1) { + return "Missing URL"; + } + // Create the test runner + var urlServer = this.params[0]; + var testRunner = new TestRunner(urlServer); + testRunner.runTests(function(failed) { + self.callback(failed ? "MWS Server tests failed" : null); + }); + return null; +}; + +function TestRunner(urlServer) { + const URL = require("node:url").URL; + this.urlServerParsed = new URL(urlServer); + this.httpLibrary = require(this.urlServerParsed.protocol === "https:" ? "https" : "http"); +} + +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, data) { + if(data?.sessionId) { + sessionId = data?.sessionId; + } + if(err) { + hasFailed = true; + console.log(`Failed "${testSpec.description}" with "${err}"`) + } + runNextTest(); + }); + } else { + if(hasFailed) { + console.log("MWS Server Tests failed"); + } else { + console.log("MWS Server Tests succeeded"); + } + callback(hasFailed); + } + } + runNextTest(); +}; + +TestRunner.prototype.runTest = function(testSpec,callback) { + const self = this; + console.log(`Running Server Test: ${testSpec.description}`) + if(testSpec.method === "GET" || testSpec.method === "POST") { + const request = this.httpLibrary.request({ + protocol: this.urlServerParsed.protocol, + host: this.urlServerParsed.hostname, + port: this.urlServerParsed.port, + path: testSpec.path, + method: testSpec.method, + headers: testSpec.headers + }, function(response) { + if (response.statusCode < 200 || response.statusCode >= 400) { + return callback(`Request failed to ${self.urlServerParsed.toString()} with status code ${response.statusCode} and ${JSON.stringify(response.headers)}`); + } + response.setEncoding("utf8"); + let buffer = ""; + response.on("data", (chunk) => { + buffer = buffer + chunk; + }); + 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", jsonData); + }); + }); + request.on("error", (e) => { + console.error(`problem with request: ${e.message}`); + }); + if(testSpec.data) { + request.write(testSpec.data); + } + request.end(); + } else { + callback("Unknown method"); + } +}; + +const testSpecs = [ + { + description: "Check index page", + method: "GET", + path: "/", + headers: { + accept: "*/*" + }, + expectedResult: (jsonData,data,headers) => { + return JSON.stringify(data).slice(1,100) === "\\n\\n\\n\\t { + return jsonData["imported-tiddlers"] && $tw.utils.isArray(jsonData["imported-tiddlers"]) && jsonData["imported-tiddlers"][0] === "One White Pixel"; + } + }, + { + description: "Create a recipe", + method: "POST", + path: "/recipes", + headers: { + "Accept": '*/*', + "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: "recipe_name=Elephants3214234&bag_names=one%20two%20three&description=A%20bag%20of%20elephants", + expectedResult: (jsonData,data,headers) => { + return headers.location === "/"; + } + } +]; + +exports.Command = Command; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/mws-server.js b/plugins/tiddlywiki/multiwikiserver/modules/mws-server.js new file mode 100644 index 00000000000..132159a8fe9 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/mws-server.js @@ -0,0 +1,596 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/mws-server.js +type: application/javascript +module-type: library + +Serve tiddlers over http + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +if($tw.node) { + var util = require("util"), + fs = require("fs"), + url = require("url"), + path = require("path"), + querystring = require("querystring"), + crypto = require("crypto"), + zlib = require("zlib"), + aclMiddleware = require('$:/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js').middleware; +} + +/* +A simple HTTP server with regexp-based routes +options: variables - optional hashmap of variables to set (a misnomer - they are really constant parameters) + routes - optional array of routes to use + wiki - reference to wiki object +*/ +function Server(options) { + var self = this; + this.routes = options.routes || []; + 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) { + for(var variable in options.variables) { + if(options.variables[variable]) { + this.variables[variable] = options.variables[variable]; + } + } + } + // Setup the default required plugins + this.requiredPlugins = this.get("required-plugins").split(','); + // Initialise CSRF + this.csrfDisable = this.get("csrf-disable") === "yes"; + // Initialize Gzip compression + this.enableGzip = this.get("gzip") === "yes"; + // Initialize browser-caching + this.enableBrowserCache = this.get("use-browser-cache") === "yes"; + // Initialise authorization + var authorizedUserName; + if(this.get("username") && this.get("password")) { + authorizedUserName = this.get("username"); + } else if(this.get("credentials")) { + authorizedUserName = "(authenticated)"; + } else { + authorizedUserName = "(anon)"; + } + this.authorizationPrincipals = { + readers: (this.get("readers") || authorizedUserName).split(",").map($tw.utils.trim), + writers: (this.get("writers") || authorizedUserName).split(",").map($tw.utils.trim) + } + if(this.get("admin") || authorizedUserName !== "(anon)") { + this.authorizationPrincipals["admin"] = (this.get("admin") || authorizedUserName).split(',').map($tw.utils.trim) + } + // Load and initialise authenticators + $tw.modules.forEachModuleOfType("authenticator", function(title,authenticatorDefinition) { + // console.log("Loading authenticator " + title); + self.addAuthenticator(authenticatorDefinition.AuthenticatorClass); + }); + // Load route handlers + $tw.modules.forEachModuleOfType("mws-route", function(title,routeDefinition) { + self.addRoute(routeDefinition); + }); + // Initialise the http vs https + this.listenOptions = null; + this.protocol = "http"; + var tlsKeyFilepath = this.get("tls-key"), + tlsCertFilepath = this.get("tls-cert"), + tlsPassphrase = this.get("tls-passphrase"); + if(tlsCertFilepath && tlsKeyFilepath) { + this.listenOptions = { + key: fs.readFileSync(path.resolve(this.boot.wikiPath,tlsKeyFilepath),"utf8"), + cert: fs.readFileSync(path.resolve(this.boot.wikiPath,tlsCertFilepath),"utf8"), + passphrase: tlsPassphrase || '' + }; + this.protocol = "https"; + } + this.transport = require(this.protocol); + // Name the server and init the boot state + this.servername = $tw.utils.transliterateToSafeASCII(this.get("server-name") || this.wiki.getTiddlerText("$:/SiteTitle") || "TiddlyWiki5"); + this.boot.origin = this.get("origin")? this.get("origin"): this.protocol+"://"+this.get("host")+":"+this.get("port"); + this.boot.pathPrefix = this.get("path-prefix") || ""; +} + +/* +Send a response to the client. This method checks if the response must be sent +or if the client alrady has the data cached. If that's the case only a 304 +response will be transmitted and the browser will use the cached data. +Only requests with status code 200 are considdered for caching. +request: request instance passed to the handler +response: response instance passed to the handler +statusCode: stauts code to send to the browser +headers: response headers (they will be augmented with an `Etag` header) +data: the data to send (passed to the end method of the response instance) +encoding: the encoding of the data to send (passed to the end method of the response instance) +*/ +function sendResponse(request,response,statusCode,headers,data,encoding) { + if(this.enableBrowserCache && (statusCode == 200)) { + var hash = crypto.createHash('md5'); + // Put everything into the hash that could change and invalidate the data that + // the browser already stored. The headers the data and the encoding. + hash.update(data); + hash.update(JSON.stringify(headers)); + if(encoding) { + hash.update(encoding); + } + var contentDigest = hash.digest("hex"); + // RFC 7232 section 2.3 mandates for the etag to be enclosed in quotes + headers["Etag"] = '"' + contentDigest + '"'; + headers["Cache-Control"] = "max-age=0, must-revalidate"; + // Check if any of the hashes contained within the if-none-match header + // matches the current hash. + // If one matches, do not send the data but tell the browser to use the + // cached data. + // We do not implement "*" as it makes no sense here. + var ifNoneMatch = request.headers["if-none-match"]; + if(ifNoneMatch) { + var matchParts = ifNoneMatch.split(",").map(function(etag) { + return etag.replace(/^[ "]+|[ "]+$/g, ""); + }); + if(matchParts.indexOf(contentDigest) != -1) { + response.writeHead(304,headers); + response.end(); + return; + } + } + } + /* + If the gzip=yes is set, check if the user agent permits compression. If so, + compress our response if the raw data is bigger than 2k. Compressing less + data is inefficient. Note that we use the synchronous functions from zlib + to stay in the imperative style. The current `Server` doesn't depend on + this, and we may just as well use the async versions. + */ + if(this.enableGzip && (data.length > 2048)) { + var acceptEncoding = request.headers["accept-encoding"] || ""; + if(/\bdeflate\b/.test(acceptEncoding)) { + headers["Content-Encoding"] = "deflate"; + data = zlib.deflateSync(data); + } else if(/\bgzip\b/.test(acceptEncoding)) { + headers["Content-Encoding"] = "gzip"; + data = zlib.gzipSync(data); + } + } + if(!response.headersSent) { + response.writeHead(statusCode,headers); + response.end(data,encoding); + } +} + +function redirect(request,response,statusCode,location) { + response.setHeader("Location",location); + response.statusCode = statusCode; + response.end() +} + +/* +Options include: +cbPartStart(headers,name,filename) - invoked when a file starts being received +cbPartChunk(chunk) - invoked when a chunk of a file is received +cbPartEnd() - invoked when a file finishes being received +cbFinished(err) - invoked when the all the form data has been processed +*/ +function streamMultipartData(request,options) { + // Check that the Content-Type is multipart/form-data + const contentType = request.headers['content-type']; + if(!contentType.startsWith("multipart/form-data")) { + return options.cbFinished("Expected multipart/form-data content type"); + } + // Extract the boundary string from the Content-Type header + const boundaryMatch = contentType.match(/boundary=(.+)$/); + if(!boundaryMatch) { + return options.cbFinished("Missing boundary in multipart/form-data"); + } + const boundary = boundaryMatch[1]; + const boundaryBuffer = Buffer.from("--" + boundary); + // Initialise + let buffer = Buffer.alloc(0); + let processingPart = false; + // Process incoming chunks + request.on("data", (chunk) => { + // Accumulate the incoming data + buffer = Buffer.concat([buffer, chunk]); + // Loop through any parts within the current buffer + while (true) { + if(!processingPart) { + // If we're not processing a part then we try to find a boundary marker + const boundaryIndex = buffer.indexOf(boundaryBuffer); + if(boundaryIndex === -1) { + // Haven't reached the boundary marker yet, so we should wait for more data + break; + } + // Look for the end of the headers + const endOfHeaders = buffer.indexOf("\r\n\r\n",boundaryIndex + boundaryBuffer.length); + if(endOfHeaders === -1) { + // Haven't reached the end of the headers, so we should wait for more data + break; + } + // Extract and parse headers + const headersPart = Uint8Array.prototype.slice.call(buffer,boundaryIndex + boundaryBuffer.length,endOfHeaders).toString(); + const currentHeaders = {}; + headersPart.split("\r\n").forEach(headerLine => { + const [key, value] = headerLine.split(": "); + currentHeaders[key.toLowerCase()] = value; + }); + // Parse the content disposition header + const contentDisposition = { + name: null, + filename: null + }; + if(currentHeaders["content-disposition"]) { + // Split the content-disposition header into semicolon-delimited parts + const parts = currentHeaders["content-disposition"].split(";").map(part => part.trim()); + // Iterate over each part to extract name and filename if they exist + parts.forEach(part => { + if(part.startsWith("name=")) { + // Remove "name=" and trim quotes + contentDisposition.name = part.substring(6,part.length - 1); + } else if(part.startsWith("filename=")) { + // Remove "filename=" and trim quotes + contentDisposition.filename = part.substring(10,part.length - 1); + } + }); + } + processingPart = true; + options.cbPartStart(currentHeaders,contentDisposition.name,contentDisposition.filename); + // Slice the buffer to the next part + buffer = Uint8Array.prototype.slice.call(buffer,endOfHeaders + 4); + } else { + const boundaryIndex = buffer.indexOf(boundaryBuffer); + if(boundaryIndex >= 0) { + // Return the part up to the boundary minus the terminating LF CR + options.cbPartChunk(Uint8Array.prototype.slice.call(buffer,0,boundaryIndex - 2)); + options.cbPartEnd(); + processingPart = false; + buffer = Uint8Array.prototype.slice.call(buffer,boundaryIndex); + } else { + // Return the rest of the buffer + options.cbPartChunk(buffer); + // Reset the buffer and wait for more data + buffer = Buffer.alloc(0); + break; + } + } + } + }); + // All done + request.on("end", () => { + options.cbFinished(null); + }); +} + +/* +Make an etag. Options include: +bag_name: +tiddler_id: +*/ +function makeTiddlerEtag(options) { + if(options.bag_name || options.tiddler_id) { + return "\"tiddler:" + options.bag_name + "/" + options.tiddler_id + "\""; + } else { + throw "Missing bag_name or tiddler_id"; + } +} + +Server.prototype.defaultVariables = { + port: "8080", + host: "127.0.0.1", + "required-plugins": "$:/plugins/tiddlywiki/filesystem,$:/plugins/tiddlywiki/tiddlyweb", + "root-tiddler": "$:/core/save/all", + "root-render-type": "text/plain", + "root-serve-type": "text/html", + "tiddler-render-type": "text/html", + "tiddler-render-template": "$:/core/templates/server/static.tiddler.html", + "system-tiddler-render-type": "text/plain", + "system-tiddler-render-template": "$:/core/templates/wikified-tiddler", + "debug-level": "none", + "gzip": "no", + "use-browser-cache": "no" +}; + +Server.prototype.get = function(name) { + return this.variables[name]; +}; + +Server.prototype.addRoute = function(route) { + this.routes.push(route); +}; + +Server.prototype.addAuthenticator = function(AuthenticatorClass) { + // Instantiate and initialise the authenticator + var authenticator = new AuthenticatorClass(this), + result = authenticator.init(); + if(typeof result === "string") { + $tw.utils.error("Error: " + result); + } else if(result) { + // Only use the authenticator if it initialised successfully + this.authenticators.push(authenticator); + } +}; + +Server.prototype.findMatchingRoute = function(request,state) { + for(var t=0; t { + 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' + user['sessionId'] = session_id + + return user +}; + +Server.prototype.requestAuthentication = function(response) { + if(!response.headersSent) { + response.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="Secure Area"' + }); + response.end('Authentication required.'); + } +}; + +// Check if the anonymous IO configuration is set to allow both reads and writes +Server.prototype.getAnonymousAccessConfig = function() { + const allowReadsTiddler = this.wiki.getTiddlerText("$:/config/MultiWikiServer/AllowAnonymousReads", "undefined"); + const allowWritesTiddler = this.wiki.getTiddlerText("$:/config/MultiWikiServer/AllowAnonymousWrites", "undefined"); + const showAnonymousAccessModal = this.wiki.getTiddlerText("$:/config/MultiWikiServer/ShowAnonymousAccessModal", "undefined"); + + return { + allowReads: allowReadsTiddler === "yes", + allowWrites: allowWritesTiddler === "yes", + isEnabled: allowReadsTiddler !== "undefined" && allowWritesTiddler !== "undefined", + showAnonConfig: showAnonymousAccessModal === "yes" + }; +} + + +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 = {}; + state.wiki = options.wiki || self.wiki; + state.boot = options.boot || self.boot; + state.server = self; + state.urlInfo = url.parse(request.url); + state.queryParameters = querystring.parse(state.urlInfo.query); + state.pathPrefix = options.pathPrefix || this.get("path-prefix") || ""; + state.sendResponse = sendResponse.bind(self,request,response); + 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 = false; //this.isAuthorized(state.authorizationType,null); + var {allowReads, allowWrites, isEnabled, showAnonConfig} = this.getAnonymousAccessConfig(); + state.anonAccessConfigured = isEnabled; + state.allowAnon = isEnabled && (request.method === 'GET' ? allowReads : allowWrites); + state.allowAnonReads = allowReads; + state.allowAnonWrites = allowWrites; + state.showAnonConfig = !!state.authenticatedUser?.isAdmin && showAnonConfig; + state.firstGuestUser = this.sqlTiddlerDatabase.listUsers().length === 0 && !state.authenticatedUser; + + // Authorize with the authenticated username + 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 && !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" && !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 + route.handler(request,response,state); + } else if(route.bodyFormat === "string" || route.bodyFormat === "www-form-urlencoded" || !route.bodyFormat) { + // Set the encoding for the incoming request + request.setEncoding("utf8"); + var data = ""; + request.on("data",function(chunk) { + data += chunk.toString(); + }); + request.on("end",function() { + if(route.bodyFormat === "www-form-urlencoded") { + data = queryString.parse(data); + } + state.data = data; + route.handler(request,response,state); + }); + } else if(route.bodyFormat === "buffer") { + var data = []; + request.on("data",function(chunk) { + data.push(chunk); + }); + request.on("end",function() { + state.data = Buffer.concat(data); + route.handler(request,response,state); + }) + } else { + response.writeHead(400,"Invalid bodyFormat " + route.bodyFormat + " in route " + route.method + " " + route.path.source); + response.end(); + } +}; + +/* +Listen for requests +port: optional port number (falls back to value of "port" variable) +host: optional host address (falls back to value of "host" variable) +prefix: optional prefix (falls back to value of "path-prefix" variable) +callback: optional callback(err) to be invoked when the listener is up and running +*/ +Server.prototype.listen = function(port,host,prefix,options) { + var self = this; + // Handle defaults for port and host + port = port || this.get("port"); + host = host || this.get("host"); + prefix = prefix || this.get("path-prefix") || ""; + // Check for the port being a string and look it up as an environment variable + if(parseInt(port,10).toString() !== port) { + port = process.env[port] || 8080; + } + // Warn if required plugins are missing + var missing = []; + for (var index=0; index 0) { + var error = "Warning: Plugin(s) required for client-server operation are missing.\n"+ + "\""+ missing.join("\", \"")+"\""; + $tw.utils.warning(error); + } + // Create the server + var server = this.transport.createServer(this.listenOptions || {},function(request,response,options) { + if(self.get("debug-level") !== "none") { + var start = $tw.utils.timer(); + response.on("finish",function() { + console.log("Response time:",request.method,request.url,$tw.utils.timer() - start); + }); + } + self.requestHandler(request,response,options); + }); + // Display the port number after we've started listening (the port number might have been specified as zero, in which case we will get an assigned port) + server.on("listening",function() { + // Stop listening when we get the "th-quit" hook + $tw.hooks.addHook("th-quit",function() { + server.close(); + }); + // Log listening details + var address = server.address(), + url = self.protocol + "://" + (address.family === "IPv6" ? "[" + address.address + "]" : address.address) + ":" + address.port + prefix; + $tw.utils.log("Serving on " + url,"brown/orange"); + $tw.utils.log("(press ctrl-C to exit)","red"); + if(options.callback) { + options.callback(null); + } + }); + // Listen + return server.listen(port,host); +}; + +exports.Server = Server; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/change-user-password.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/change-user-password.js new file mode 100644 index 00000000000..303b8e4e6e6 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/change-user-password.js @@ -0,0 +1,91 @@ +/*\ +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) { + var userId = state.data.userId; + // Clean up any existing error/success messages + $tw.mws.store.adminWiki.deleteTiddler("$:/temp/mws/change-password/" + userId + "/error"); + $tw.mws.store.adminWiki.deleteTiddler("$:/temp/mws/change-password/" + userId + "/success"); + $tw.mws.store.adminWiki.deleteTiddler("$:/temp/mws/login/error"); + + if(!state.authenticatedUser) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/login/error", + text: "You must be logged in to change passwords" + })); + response.writeHead(302, { "Location": "/login" }); + response.end(); + return; + } + + var auth = authenticator(state.server.sqlTiddlerDatabase); + var newPassword = state.data.newPassword; + var confirmPassword = state.data.confirmPassword; + var currentUserId = state.authenticatedUser.user_id; + + var hasPermission = ($tw.utils.parseInt(userId) === currentUserId) || state.authenticatedUser.isAdmin; + + if(!hasPermission) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/change-password/" + userId + "/error", + text: "You don't have permission to change this user's password" + })); + response.writeHead(302, { "Location": "/admin/users/" + userId }); + response.end(); + return; + } + + if(newPassword !== confirmPassword) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/change-password/" + userId + "/error", + text: "New passwords do not match" + })); + response.writeHead(302, { "Location": "/admin/users/" + userId }); + response.end(); + return; + } + + var userData = state.server.sqlTiddlerDatabase.getUser(userId); + + if(!userData) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/change-password/" + userId + "/error", + text: "User not found" + })); + response.writeHead(302, { "Location": "/admin/users/" + userId }); + response.end(); + return; + } + + var newHash = auth.hashPassword(newPassword); + var result = state.server.sqlTiddlerDatabase.updateUserPassword(userId, newHash); + + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/change-password/" + userId + "/success", + text: result.message + })); + response.writeHead(302, { "Location": "/admin/users/" + userId }); + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-acl.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-acl.js new file mode 100644 index 00000000000..a4c4768e4e5 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-acl.js @@ -0,0 +1,41 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/delete-acl.js +type: application/javascript +module-type: mws-route + +POST /admin/delete-acl + +\*/ +(function () { + + /*jslint node: true, browser: true */ + /*global $tw: false */ + "use strict"; + + var aclMiddleware = require("$:/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js").middleware; + + exports.method = "POST"; + + exports.path = /^\/admin\/delete-acl\/?$/; + + + exports.bodyFormat = "www-form-urlencoded"; + + exports.csrfDisable = true; + + exports.handler = function (request, response, state) { + var sqlTiddlerDatabase = state.server.sqlTiddlerDatabase; + var recipe_name = state.data.recipe_name; + var bag_name = state.data.bag_name; + var acl_id = state.data.acl_id; + var entity_type = state.data.entity_type; + + aclMiddleware(request, response, state, entity_type, "WRITE"); + + sqlTiddlerDatabase.deleteACL(acl_id); + + response.writeHead(302, { "Location": "/admin/acl/" + recipe_name + "/" + bag_name }); + response.end(); + }; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-bag-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-bag-tiddler.js new file mode 100644 index 00000000000..722ef2d8c09 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-bag-tiddler.js @@ -0,0 +1,44 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/delete-bag-tiddler.js +type: application/javascript +module-type: mws-route + +DELETE /bags/:bag_name/tiddler/:title + +\*/ +(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 = "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) { + 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 { + if(!response.headersSent) { + response.writeHead(404); + response.end(); + } + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-role.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-role.js new file mode 100644 index 00000000000..571545b1519 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-role.js @@ -0,0 +1,54 @@ +/*\ +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) { + sqlTiddlerDatabase.deleteUserRolesByRoleId(role_id); + } + + // Delete the role + sqlTiddlerDatabase.deleteRole(role_id); + // Redirect back to the roles management page + response.writeHead(302, { "Location": "/admin/roles" }); + response.end(); + }; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-user-account.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-user-account.js new file mode 100644 index 00000000000..5fb0f219f28 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/delete-user-account.js @@ -0,0 +1,93 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/delete-user-account.js +type: application/javascript +module-type: mws-route + +POST /delete-user-account + +\*/ +(function () { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "POST"; + +exports.path = /^\/delete-user-account\/?$/; + +exports.bodyFormat = "www-form-urlencoded"; + +exports.csrfDisable = true; + +exports.handler = function (request, response, state) { + var sqlTiddlerDatabase = state.server.sqlTiddlerDatabase; + var userId = state.data.userId; + + // Check if user is admin + if(!state.authenticatedUser || !state.authenticatedUser.isAdmin) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/delete-user/error", + text: "You must be an administrator to delete user accounts" + })); + response.writeHead(302, { "Location": '/admin/users/'+userId }); + response.end(); + return; + } + + // Prevent admin from deleting their own account + if(state.authenticatedUser.user_id === userId) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/delete-user/error", + text: "Cannot delete your own account" + })); + response.writeHead(302, { "Location": '/admin/users/'+userId }); + response.end(); + return; + } + + // Check if the user exists + var user = sqlTiddlerDatabase.getUser(userId); + if(!user) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/delete-user/error", + text: "User not found" + })); + response.writeHead(302, { "Location": '/admin/users/'+userId }); + response.end(); + return; + } + + // Check if this is the last admin account + var adminRole = sqlTiddlerDatabase.getRoleByName("ADMIN"); + if(!adminRole) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/delete-user/error", + text: "Admin role not found" + })); + response.writeHead(302, { "Location": '/admin/users/'+userId }); + response.end(); + return; + } + + var adminUsers = sqlTiddlerDatabase.listUsersByRoleId(adminRole.role_id); + if(adminUsers.length <= 1 && adminUsers.some(admin => admin.user_id === parseInt(userId))) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/delete-user/error", + text: "Cannot delete the last admin account" + })); + response.writeHead(302, { "Location": '/admin/users/'+userId }); + response.end(); + return; + } + + sqlTiddlerDatabase.deleteUserRolesByUserId(userId); + sqlTiddlerDatabase.deleteUserSessions(userId); + sqlTiddlerDatabase.deleteUser(userId); + + // Redirect back to the users management page + response.writeHead(302, { "Location": "/admin/users" }); + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-acl.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-acl.js new file mode 100644 index 00000000000..1c6e2f1b7e8 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-acl.js @@ -0,0 +1,100 @@ +/*\ +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?.isAdmin && + !state.firstGuestUser && + (!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 : state.firstGuestUser ? "Anonymous User" : "Guest", + "user-is-admin": state.authenticatedUser && state.authenticatedUser.isAdmin ? "yes" : "no" + } + }); + + response.write(html); + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler-blob.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler-blob.js new file mode 100644 index 00000000000..28d23212c43 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler-blob.js @@ -0,0 +1,43 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag-tiddler-blob.js +type: application/javascript +module-type: mws-route + +GET /bags/:bag_name/tiddler/:title/blob + +\*/ +(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 = "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 && !response.headersSent) { + response.writeHead(200, "OK",{ + Etag: state.makeTiddlerEtag(result), + "Content-Type": result.type, + }); + result.stream.pipe(response); + return; + } + } + if (!response.headersSent) { + response.writeHead(404); + response.end(); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler.js new file mode 100644 index 00000000000..52b169652b0 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag-tiddler.js @@ -0,0 +1,79 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag-tiddler.js +type: application/javascript +module-type: mws-route + +GET /bags/:bag_name/tiddler/:title + +Parameters: + +fallback= // Optional redirect if the tiddler is not found + +\*/ +(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 = "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]), + tiddlerInfo = $tw.mws.store.getBagTiddler(title,bag_name); + if(tiddlerInfo && tiddlerInfo.tiddler) { + // 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,{ + Etag: state.makeTiddlerEtag(tiddlerInfo), + "Content-Type": "application/json" + },JSON.stringify(tiddlerInfo.tiddler),"utf8"); + return; + } else { + // 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) { + if(!response.headersSent){ + response.writeHead(200, "OK",{ + Etag: state.makeTiddlerEtag(result), + "Content-Type": result.type + }); + } + result.stream.pipe(response); + return; + } else { + if(!response.headersSent){ + response.writeHead(404); + response.end(); + } + return; + } + } + } else { + // Redirect to fallback URL if tiddler not found + if(state.queryParameters.fallback) { + if (!response.headersSent){ + response.writeHead(302, "OK",{ + "Location": state.queryParameters.fallback + }); + response.end(); + } + return; + } else { + if(!response.headersSent){ + response.writeHead(404); + response.end(); + } + return; + } + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag.js new file mode 100644 index 00000000000..7d262b83fcf --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-bag.js @@ -0,0 +1,63 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag.js +type: application/javascript +module-type: mws-route + +GET /bags/:bag_name/ +GET /bags/:bag_name + +\*/ +(function () { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "GET"; + +exports.path = /^\/bags\/([^\/]+)(\/?)$/; + +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 + "/"); + 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 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"); + } else { + 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 { + if (!response.headersSent) { + response.writeHead(404); + response.end(); + } + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-index.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-index.js new file mode 100644 index 00000000000..1b5dc96fe6f --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-index.js @@ -0,0 +1,62 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-index.js +type: application/javascript +module-type: mws-route + +GET /?show_system=true + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "GET"; + +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(), + 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"); + } else { + // This is not a JSON API request, we should return the raw tiddler content + response.writeHead(200, "OK",{ + "Content-Type": "text/html" + }); + // filter bags and recipies by user's read access from ACL + var allowedRecipes = recipeList.filter(recipe => recipe.recipe_name.startsWith("$:/") || state.authenticatedUser?.isAdmin || sqlTiddlerDatabase.hasRecipePermission(state.authenticatedUser?.user_id, recipe.recipe_name, 'READ') || state.allowAnon && state.allowAnonReads); + var allowedBags = bagList.filter(bag => bag.bag_name.startsWith("$:/") || state.authenticatedUser?.isAdmin || sqlTiddlerDatabase.hasBagPermission(state.authenticatedUser?.user_id, bag.bag_name, 'READ') || state.allowAnon && state.allowAnonReads); + allowedRecipes = allowedRecipes.map(recipe => { + return { + ...recipe, + has_acl_access: state.authenticatedUser?.isAdmin || recipe.owner_id === state.authenticatedUser?.user_id || sqlTiddlerDatabase.hasRecipePermission(state.authenticatedUser?.user_id, recipe.recipe_name, 'WRITE') + } + }); + // 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(allowedBags), + "recipe-list": JSON.stringify(allowedRecipes), + "username": state.authenticatedUser ? state.authenticatedUser.username : state.firstGuestUser ? "Anonymous User" : "Guest", + "user-is-admin": state.authenticatedUser && state.authenticatedUser.isAdmin ? "yes" : "no", + "first-guest-user": state.firstGuestUser ? "yes" : "no", + "show-anon-config": state.showAnonConfig ? "yes" : "no", + "user-is-logged-in": !!state.authenticatedUser ? "yes" : "no", + "user": JSON.stringify(state.authenticatedUser), + "has-profile-access": !!state.authenticatedUser ? "yes" : "no" + }}); + response.write(html); + response.end(); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-login.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-login.js new file mode 100644 index 00000000000..dd0421a66ea --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-login.js @@ -0,0 +1,39 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-login.js +type: application/javascript +module-type: mws-route + +GET /login + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "GET"; + +exports.path = /^\/login$/; + +exports.handler = function(request,response,state) { + // Check if the user already has a valid session + var authenticatedUser = state.server.authenticateUser(request, response); + if(authenticatedUser) { + // User is already logged in, redirect to home page + response.writeHead(302, { "Location": "/" }); + response.end(); + return; + } + var loginTiddler = $tw.mws.store.adminWiki.getTiddler("$:/plugins/tiddlywiki/multiwikiserver/auth/form/login"); + if(loginTiddler) { + var text = $tw.mws.store.adminWiki.renderTiddler("text/html", loginTiddler.fields.title); + response.writeHead(200, { "Content-Type": "text/html" }); + response.end(text); + } else { + response.writeHead(404); + response.end("Login page not found"); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-events.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-events.js new file mode 100644 index 00000000000..bfdc40c187d --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-events.js @@ -0,0 +1,88 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-recipe-events.js +type: application/javascript +module-type: mws-route + +GET /recipes/:recipe_name/events + +headers: + +Last-Event-ID: + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +const SSE_HEARTBEAT_INTERVAL_MS = 10 * 1000; + +exports.method = "GET"; + +exports.path = /^\/recipes\/([^\/]+)\/events$/; + +exports.handler = function(request,response,state) { + // Get the parameters + const recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]); + let last_known_tiddler_id = 0; + if(request.headers["Last-Event-ID"]) { + last_known_tiddler_id = $tw.utils.parseNumber(request.headers["Last-Event-ID"]); + } else if(state.queryParameters.last_known_tiddler_id) { + last_known_tiddler_id = $tw.utils.parseNumber(state.queryParameters.last_known_tiddler_id); + } + if(recipe_name) { + // Start streaming the response + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive" + }); + // Setup the heartbeat timer + var heartbeatTimer = setInterval(function() { + response.write(':keep-alive\n\n'); + },SSE_HEARTBEAT_INTERVAL_MS); + // Method to get changed tiddler events and send to the client + function sendUpdates() { + // Get the tiddlers in the recipe since the last known tiddler_id + var recipeTiddlers = $tw.mws.store.getRecipeTiddlers(recipe_name,{ + include_deleted: true, + last_known_tiddler_id: last_known_tiddler_id + }); + // Send to the client + if(recipeTiddlers) { + for(let index = recipeTiddlers.length-1; index>=0; index--) { + const tiddlerInfo = recipeTiddlers[index]; + if(tiddlerInfo.tiddler_id > last_known_tiddler_id) { + last_known_tiddler_id = tiddlerInfo.tiddler_id; + } + response.write(`event: change\n`) + let data = tiddlerInfo; + if(!tiddlerInfo.is_deleted) { + const tiddler = $tw.mws.store.getRecipeTiddler(tiddlerInfo.title,recipe_name); + if(tiddler) { + data = $tw.utils.extend({},data,{tiddler: tiddler.tiddler}) + } + } + response.write(`data: ${JSON.stringify(data)}\n`); + response.write(`id: ${tiddlerInfo.tiddler_id}\n`) + response.write(`\n`); + } + } + } + // Send current and future changes + sendUpdates(); + $tw.mws.store.addEventListener("change",sendUpdates); + // Clean up when the connection closes + response.on("close",function () { + clearInterval(heartbeatTimer); + $tw.mws.store.removeEventListener("change",sendUpdates); + }); + return; + } + // Fail if something went wrong + response.writeHead(404); + response.end(); +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddler.js new file mode 100644 index 00000000000..a50657ce58a --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddler.js @@ -0,0 +1,81 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-recipe-tiddler.js +type: application/javascript +module-type: mws-route + +GET /recipes/:recipe_name/tiddler/:title + +Parameters: + +fallback= // Optional redirect if the tiddler is not found + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +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]), + title = $tw.utils.decodeURIComponentSafe(state.params[1]), + tiddlerInfo = $tw.mws.store.getRecipeTiddler(title,recipe_name); + if(tiddlerInfo && tiddlerInfo.tiddler) { + // 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,{ + "X-Revision-Number": tiddlerInfo.tiddler_id, + "X-Bag-Name": tiddlerInfo.bag_name, + Etag: state.makeTiddlerEtag(tiddlerInfo), + "Content-Type": "application/json" + },JSON.stringify(tiddlerInfo.tiddler),"utf8"); + return; + } else { + // This is not a JSON API request, we should return the raw tiddler content + const result = $tw.mws.store.getBagTiddlerStream(title,tiddlerInfo.bag_name); + if(result) { + if(!response.headersSent){ + response.writeHead(200, "OK",{ + Etag: state.makeTiddlerEtag(result), + "Content-Type": result.type + }); + } + result.stream.pipe(response); + return; + } else { + if(!response.headersSent){ + response.writeHead(404); + response.end(); + } + return; + } + } + } else { + if(!response.headersSent) { + // Redirect to fallback URL if tiddler not found + if(state.queryParameters.fallback) { + response.writeHead(302, "OK",{ + "Location": state.queryParameters.fallback + }); + response.end(); + return; + } else { + response.writeHead(404); + response.end(); + return; + } + } + return; + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddlers-json.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddlers-json.js new file mode 100644 index 00000000000..e16e3d10a55 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-recipe-tiddlers-json.js @@ -0,0 +1,41 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-recipe-tiddlers-json.js +type: application/javascript +module-type: mws-route + +GET /recipes/:recipe_name/tiddlers.json?last_known_tiddler_id=:last_known_tiddler_id&include_deleted=true|false + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "GET"; + +exports.path = /^\/recipes\/([^\/]+)\/tiddlers.json$/; + +exports.handler = function(request,response,state) { + 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(); + } + +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-system.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-system.js new file mode 100644 index 00000000000..a5346c1a658 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-system.js @@ -0,0 +1,52 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-system.js +type: application/javascript +module-type: mws-route + +Retrieves a system file. System files are stored in configuration tiddlers with the following fields: + +* title: "$:/plugins/tiddlywiki/multiwikiserver/system-files/" suffixed with the name of the file +* tags: tagged $:/tags/MWS/SystemFile or $:/tags/MWS/SystemFileWikified +* system-file-type: optionally specify the MIME type that should be returned for the file + +GET /.system/:filename + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "GET"; + +exports.path = /^\/\.system\/(.+)$/; + +const SYSTEM_FILE_TITLE_PREFIX = "$:/plugins/tiddlywiki/multiwikiserver/system-files/"; + +exports.handler = function(request,response,state) { + // Get the parameters + const filename = $tw.utils.decodeURIComponentSafe(state.params[0]), + title = SYSTEM_FILE_TITLE_PREFIX + filename, + tiddler = $tw.wiki.getTiddler(title), + isSystemFile = tiddler && tiddler.hasTag("$:/tags/MWS/SystemFile"), + isSystemFileWikified = tiddler && tiddler.hasTag("$:/tags/MWS/SystemFileWikified"); + if(tiddler && (isSystemFile || isSystemFileWikified)) { + let text = tiddler.fields.text || ""; + const type = tiddler.fields["system-file-type"] || tiddler.fields.type || "text/plain", + encoding = ($tw.config.contentTypeInfo[type] ||{encoding: "utf8"}).encoding; + if(isSystemFileWikified) { + text = $tw.wiki.renderTiddler("text/plain",title); + } + response.writeHead(200, "OK",{ + "Content-Type": type + }); + response.write(text,encoding); + response.end(); + } else { + response.writeHead(404); + response.end(); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-users.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-users.js new file mode 100644 index 00000000000..142258aa0d5 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-users.js @@ -0,0 +1,65 @@ +/*\ +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(); + if (request.url.includes("*")) { + $tw.mws.store.adminWiki.deleteTiddler("$:/temp/mws/post-user/error"); + $tw.mws.store.adminWiki.deleteTiddler("$:/temp/mws/post-user/success"); + } + + // Ensure userList is an array + if (!Array.isArray(userList)) { + userList = []; + console.error("userList is not an array"); + } + + if(!state.authenticatedUser.isAdmin && !state.firstGuestUser) { + response.writeHead(403, "Forbidden", { "Content-Type": "text/plain" }); + response.end("Forbidden"); + return; + } + + // 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 : state.firstGuestUser ? "Anonymous User" : "Guest", + "user-is-admin": state.authenticatedUser && state.authenticatedUser.isAdmin ? "yes" : "no", + "first-guest-user": state.firstGuestUser ? "yes" : "no" + } + }); + response.write(html); + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-wiki.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-wiki.js new file mode 100644 index 00000000000..1765f5e208f --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/get-wiki.js @@ -0,0 +1,97 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-wiki.js +type: application/javascript +module-type: mws-route + +GET /wiki/:recipe_name + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +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]), + recipeTiddlers = recipe_name && $tw.mws.store.getRecipeTiddlers(recipe_name); + // Check request is valid + if(recipe_name && recipeTiddlers) { + // Start the response + response.writeHead(200, "OK",{ + "Content-Type": "text/html" + }); + // Get the tiddlers in the recipe + // Render the template + var template = $tw.mws.store.adminWiki.renderTiddler("text/plain","$:/core/templates/tiddlywiki5.html",{ + variables: { + saveTiddlerFilter: ` + $:/boot/boot.css + $:/boot/boot.js + $:/boot/bootprefix.js + $:/core + $:/library/sjcl.js + $:/plugins/tiddlywiki/multiwikiclient + $:/themes/tiddlywiki/snowwhite + $:/themes/tiddlywiki/vanilla + ` + } + }); + // Splice in our tiddlers + var marker = `<` + `script class="tiddlywiki-tiddler-store" type="application/json">[`, + markerPos = template.indexOf(marker); + if(markerPos === -1) { + throw new Error("Cannot find tiddler store in template"); + } + function writeTiddler(tiddlerFields) { + response.write(JSON.stringify(tiddlerFields).replace(/ name[0]).join(""), + "user-role": JSON.stringify(userRole), + "all-roles": JSON.stringify(allRoles), + "first-guest-user": state.firstGuestUser ? "yes" : "no", + "is-current-user-profile": state.authenticatedUser && state.authenticatedUser.user_id === $tw.utils.parseInt(user_id, 10) ? "yes" : "no", + "username": state.authenticatedUser ? state.authenticatedUser.username : state.firstGuestUser ? "Anonymous User" : "Guest", + "user-is-admin": state.authenticatedUser && state.authenticatedUser.isAdmin ? "yes" : "no", + "user-id": user_id, + "user-is-logged-in": !!state.authenticatedUser ? "yes" : "no", + "has-profile-access": !!state.authenticatedUser ? "yes" : "no" + } + }); + response.write(html); + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-acl.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-acl.js new file mode 100644 index 00000000000..63a9f414f1b --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-acl.js @@ -0,0 +1,68 @@ +/*\ +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" + + try { + 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(); + } catch (error) { + response.writeHead(302, { "Location": "/admin/acl/" + recipe_name + "/" + bag_name }); + response.end(); + } +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-anon-config.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-anon-config.js new file mode 100644 index 00000000000..e1e841516c4 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-anon-config.js @@ -0,0 +1,54 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-anon-config.js +type: application/javascript +module-type: mws-route + +POST /admin/post-anon-config + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "POST"; + +exports.path = /^\/admin\/post-anon-config\/?$/; + +exports.bodyFormat = "www-form-urlencoded"; + +exports.csrfDisable = true; + +exports.handler = function(request, response, state) { + // Check if user is authenticated and is admin + if(!state.authenticatedUser || !state.authenticatedUser.isAdmin) { + response.writeHead(401, "Unauthorized", { "Content-Type": "text/plain" }); + response.end("Unauthorized"); + return; + } + + var allowReads = state.data.allowReads === "on"; + var allowWrites = state.data.allowWrites === "on"; + + // Update the configuration tiddlers + var wiki = $tw.wiki; + wiki.addTiddler({ + title: "$:/config/MultiWikiServer/AllowAnonymousReads", + text: allowReads ? "yes" : "no" + }); + wiki.addTiddler({ + title: "$:/config/MultiWikiServer/AllowAnonymousWrites", + text: allowWrites ? "yes" : "no" + }); + + wiki.addTiddler({ + title: "$:/config/MultiWikiServer/ShowAnonymousAccessModal", + text: "no" + }); + // Redirect back to admin page + response.writeHead(302, {"Location": "/"}); + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-anon.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-anon.js new file mode 100644 index 00000000000..911b6ef971c --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-anon.js @@ -0,0 +1,44 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-anon.js +type: application/javascript +module-type: mws-route + +POST /admin/anon + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "POST"; + +exports.path = /^\/admin\/anon\/?$/; + +exports.bodyFormat = "www-form-urlencoded"; + +exports.csrfDisable = true; + +exports.handler = function(request, response, state) { + // Check if user is authenticated and is admin + if(!state.authenticatedUser || !state.authenticatedUser.isAdmin) { + response.writeHead(401, "Unauthorized", { "Content-Type": "text/plain" }); + response.end("Unauthorized"); + return; + } + + + // Update the configuration tiddlers + var wiki = $tw.wiki; + wiki.addTiddler({ + title: "$:/config/MultiWikiServer/ShowAnonymousAccessModal", + text: "yes" + }); + + // Redirect back to admin page + response.writeHead(302, {"Location": "/"}); + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag-tiddlers.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag-tiddlers.js new file mode 100644 index 00000000000..0f520b1ba59 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag-tiddlers.js @@ -0,0 +1,76 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-bag-tiddlers.js +type: application/javascript +module-type: mws-route + +POST /bags/:bag_name/tiddlers/ + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "POST"; + +exports.path = /^\/bags\/([^\/]+)\/tiddlers\/$/; + +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"), + processIncomingStream = require("$:/plugins/tiddlywiki/multiwikiserver/routes/helpers/multipart-forms.js").processIncomingStream; + // Get the parameters + var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]); + // Process the incoming data + processIncomingStream({ + store: $tw.mws.store, + state: state, + response: response, + bag_name: bag_name, + callback: function(err,results) { + // 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({ + "imported-tiddlers": results + })); + } else { + if(!response.headersSent) { + response.writeHead(200, "OK",{ + "Content-Type": "text/html" + }); + response.write(` + + + + + + `); + // Render the html + var html = $tw.mws.store.adminWiki.renderTiddler("text/html","$:/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers",{ + variables: { + "bag-name": bag_name, + "imported-titles": JSON.stringify(results) + } + }); + response.write(html); + response.write(` + + + `); + response.end(); + } + } + } + }); +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag.js new file mode 100644 index 00000000000..bd59b06427f --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-bag.js @@ -0,0 +1,54 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-bag.js +type: application/javascript +module-type: mws-route + +POST /bags + +Parameters: + +bag_name +description + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "POST"; + +exports.path = /^\/bags$/; + +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); + if(!result) { + state.sendResponse(302,{ + "Content-Type": "text/plain", + "Location": "/" + }); + } else { + state.sendResponse(400,{ + "Content-Type": "text/plain" + }, + result.message, + "utf8"); + } + } else { + state.sendResponse(400,{ + "Content-Type": "text/plain" + }); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-login.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-login.js new file mode 100644 index 00000000000..b2bc0ff4df4 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-login.js @@ -0,0 +1,67 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-login.js +type: application/javascript +module-type: mws-route + +POST /login + +Parameters: + +username +password + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; +var authenticator = require('$:/plugins/tiddlywiki/multiwikiserver/auth/authentication.js').Authenticator; + +exports.method = "POST"; + +exports.path = /^\/login$/; + +exports.bodyFormat = "www-form-urlencoded"; + +exports.csrfDisable = true; + +exports.handler = function(request,response,state) { + var auth = authenticator(state.server.sqlTiddlerDatabase); + var username = state.data.username; + var password = state.data.password; + var user = state.server.sqlTiddlerDatabase.getUserByUsername(username); + var isPasswordValid = auth.verifyPassword(password, user ? user.password : null) + + if(user && isPasswordValid) { + var sessionId = auth.createSession(user.user_id); + var returnUrl = state.server.parseCookieString(request.headers.cookie).returnUrl + response.setHeader('Set-Cookie', `session=${sessionId}; HttpOnly; Path=/`); + if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) { + state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify({ + "sessionId": sessionId + })); + } else { + response.writeHead(302, { + 'Location': returnUrl || '/' + }); + } + } else { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/login/error", + text: "Invalid username or password" + })); + if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) { + state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify({ + "message": "Invalid username or password" + })); + } else { + response.writeHead(302, { + 'Location': '/login' + }); + } + } + response.end(); +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-logout.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-logout.js new file mode 100644 index 00000000000..36d901b4467 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-logout.js @@ -0,0 +1,37 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-logout.js +type: application/javascript +module-type: mws-route + +POST /logout + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "POST"; + +exports.path = /^\/logout$/; + +exports.csrfDisable = true; + +exports.handler = function(request,response,state) { + // if(state.authenticatedUser) { + state.server.sqlTiddlerDatabase.deleteSession(state.authenticatedUser.sessionId); + // } + var cookies = request.headers.cookie ? request.headers.cookie.split(";") : []; + for(var i = 0; i < cookies.length; i++) { + var cookie = cookies[i].trim().split("=")[0]; + response.setHeader("Set-Cookie", cookie + "=; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Strict"); + } + + // response.setHeader("Set-Cookie", "session=; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT"); + // response.setHeader("Set-Cookie", "returnUrl=; HttpOnly; Path=/"); + response.writeHead(302, { "Location": "/login" }); + response.end(); +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-recipe.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-recipe.js new file mode 100644 index 00000000000..aa38986a002 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-recipe.js @@ -0,0 +1,60 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-recipe.js +type: application/javascript +module-type: mws-route + +POST /recipes + +Parameters: + +recipe_name +description +bag_names: space separated list of bags + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "POST"; + +exports.path = /^\/recipes$/; + +exports.bodyFormat = "www-form-urlencoded"; + +exports.csrfDisable = true; + +exports.useACL = true; + +exports.entityName = "recipe" + +exports.handler = function(request,response,state) { + var server = state.server, + sqlTiddlerDatabase = server.sqlTiddlerDatabase + 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); + if(!result) { + if(state.authenticatedUser) { + sqlTiddlerDatabase.assignRecipeToUser(state.data.recipe_name,state.authenticatedUser.user_id); + } + state.sendResponse(302,{ + "Content-Type": "text/plain", + "Location": "/" + }); + } else { + state.sendResponse(400,{ + "Content-Type": "text/plain" + }, + result.message, + "utf8"); + } + } else { + state.sendResponse(400,{ + "Content-Type": "text/plain" + }); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-role.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-role.js new file mode 100644 index 00000000000..9692c7d689d --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-role.js @@ -0,0 +1,82 @@ +/*\ +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; + + if(!state.authenticatedUser || !state.authenticatedUser.isAdmin) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-role/error", + text: "Unauthorized access. Admin privileges required." + })); + response.writeHead(302, { "Location": "/login" }); + response.end(); + return; + } + + if(!role_name || !role_description) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-role/error", + text: "Role name and description are required" + })); + response.writeHead(302, { "Location": "/admin/roles" }); + response.end(); + return; + } + + try { + // Check if role already exists + var existingRole = sqlTiddlerDatabase.getRole(role_name); + if(existingRole) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-role/error", + text: "Role already exists" + })); + response.writeHead(302, { "Location": "/admin/roles" }); + response.end(); + return; + } + + sqlTiddlerDatabase.createRole(role_name, role_description); + + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-role/success", + text: "Role created successfully" + })); + response.writeHead(302, { "Location": "/admin/roles" }); + response.end(); + + } catch(error) { + console.error("Error creating role:", error); + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-role/error", + text: "Error creating role: " + error.message + })); + response.writeHead(302, { "Location": "/admin/roles" }); + response.end(); + return; + } +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-user.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-user.js new file mode 100644 index 00000000000..ff3acbfc916 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/post-user.js @@ -0,0 +1,191 @@ +/*\ +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"; +if($tw.node) { + var crypto = require("crypto"); +} +exports.method = "POST"; + +exports.path = /^\/admin\/post-user\/?$/; + +exports.bodyFormat = "www-form-urlencoded"; + +exports.csrfDisable = true; + +function deleteTempTiddlers() { + setTimeout(function() { + $tw.mws.store.adminWiki.deleteTiddler("$:/temp/mws/queryParams"); + $tw.mws.store.adminWiki.deleteTiddler("$:/temp/mws/post-user/error"); + $tw.mws.store.adminWiki.deleteTiddler("$:/temp/mws/post-user/success"); + }, 1000); +} + +exports.handler = function(request, response, state) { + var current_user_id = state.authenticatedUser.user_id; + 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; + var queryParamsTiddlerTitle = "$:/temp/mws/queryParams"; + + if(!state.authenticatedUser && !state.firstGuestUser) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-user/error", + text: "Unauthorized access" + })); + response.writeHead(302, { "Location": "/login" }); + response.end(); + deleteTempTiddlers(); + return; + } + + if(!username || !email || !password || !confirmPassword) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-user/error", + text: "All fields are required" + })); + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: queryParamsTiddlerTitle, + username: username, + email: email, + })); + response.writeHead(302, { "Location": "/admin/users" }); + response.end(); + deleteTempTiddlers(); + return; + } + + if(password !== confirmPassword) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-user/error", + text: "Passwords do not match" + })); + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/queryParams", + username: username, + email: email, + })); + response.writeHead(302, { "Location": "/admin/users" }); + response.end(); + deleteTempTiddlers(); + return; + } + + try { + // Check if username or email already exists + var existingUser = sqlTiddlerDatabase.getUserByUsername(username); + var existingUserByEmail = sqlTiddlerDatabase.getUserByEmail(email); + + if(existingUser || existingUserByEmail) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-user/error", + text: existingUser ? "User with this username already exists" : "User account with this email already exists" + })); + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: queryParamsTiddlerTitle, + username: username, + email: email, + })); + + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/queryParams", + username: username, + email: email, + })); + response.writeHead(302, { "Location": "/admin/users" }); + response.end(); + deleteTempTiddlers(); + return; + } + + var hasUsers = sqlTiddlerDatabase.listUsers().length > 0; + var hashedPassword = crypto.createHash("sha256").update(password).digest("hex"); + + // Create new user + var userId = sqlTiddlerDatabase.createUser(username, email, hashedPassword); + + if(!hasUsers) { + try { + // If this is the first guest user, assign admin privileges + sqlTiddlerDatabase.setUserAdmin(userId, true); + + // Create a session for the new admin user + var auth = require("$:/plugins/tiddlywiki/multiwikiserver/auth/authentication.js").Authenticator; + var authenticator = auth(sqlTiddlerDatabase); + var sessionId = authenticator.createSession(userId); + + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-user/success", + text: "Admin user created successfully" + })); + response.setHeader("Set-Cookie", "session="+sessionId+"; HttpOnly; Path=/"); + response.writeHead(302, {"Location": "/"}); + response.end(); + deleteTempTiddlers(); + return; + } catch(adminError) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-user/error", + text: "Error creating admin user" + })); + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: queryParamsTiddlerTitle, + username: username, + email: email, + })); + response.writeHead(302, { "Location": "/admin/users" }); + response.end(); + deleteTempTiddlers(); + return; + } + } else { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-user/success", + text: "User created successfully" + })); + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: queryParamsTiddlerTitle, + username: username, + email: email, + })); + // assign role to user + var roles = sqlTiddlerDatabase.listRoles(); + var role = roles.find(function(role) { + return role.role_name.toUpperCase() !== "ADMIN"; + }); + if(role) { + sqlTiddlerDatabase.addRoleToUser(userId, role.role_id); + } + response.writeHead(302, {"Location": "/admin/users/"+userId}); + response.end(); + deleteTempTiddlers(); + } + } catch(error) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/post-user/error", + text: "Error creating user: " + error.message + })); + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: queryParamsTiddlerTitle, + username: username, + email: email, + })); + response.writeHead(302, { "Location": "/admin/users" }); + response.end(); + deleteTempTiddlers(); + return; + } +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-bag.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-bag.js new file mode 100644 index 00000000000..d174ee8cea2 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-bag.js @@ -0,0 +1,48 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-bag.js +type: application/javascript +module-type: mws-route + +PUT /bags/:bag_name + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +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) { + var result = $tw.mws.store.createBag(bag_name,data.description); + if(!result) { + state.sendResponse(204,{ + "Content-Type": "text/plain" + }); + } else { + state.sendResponse(400,{ + "Content-Type": "text/plain" + }, + result.message, + "utf8"); + } + } else { + if(!response.headersSent) { + response.writeHead(404); + response.end(); + } + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe-tiddler.js new file mode 100644 index 00000000000..25279cdd0d8 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe-tiddler.js @@ -0,0 +1,52 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-recipe-tiddler.js +type: application/javascript +module-type: mws-route + +PUT /recipes/:recipe_name/tiddlers/:title + +\*/ +(function () { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "PUT"; + +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]), + 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(!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(); + } + return; + } + // Fail if something went wrong + if(!response.headersSent) { + response.writeHead(404); + response.end(); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe.js new file mode 100644 index 00000000000..002c5e4dbd9 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/put-recipe.js @@ -0,0 +1,48 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-recipe.js +type: application/javascript +module-type: mws-route + +PUT /recipes/:recipe_name + +\*/ +(function () { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "PUT"; + +exports.path = /^\/recipes\/(.+)$/; + +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) { + var result = $tw.mws.store.createRecipe(recipe_name, data.bag_names, data.description); + if(!result) { + state.sendResponse(204, { + "Content-Type": "text/plain" + }); + } else { + state.sendResponse(400, { + "Content-Type": "text/plain" + }, + result.message, + "utf8"); + } + } else { + if(!response.headersSent) { + response.writeHead(404); + response.end(); + } + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/update-role.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/update-role.js new file mode 100644 index 00000000000..081ba9b7374 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/update-role.js @@ -0,0 +1,66 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/update-role.js +type: application/javascript +module-type: mws-route + +POST /admin/roles/:id + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "POST"; + +exports.path = /^\/admin\/roles\/([^\/]+)\/?$/; + +exports.bodyFormat = "www-form-urlencoded"; + +exports.csrfDisable = true; + +exports.handler = function(request, response, state) { + var sqlTiddlerDatabase = state.server.sqlTiddlerDatabase; + var role_id = state.params[0]; + var role_name = state.data.role_name; + var role_description = state.data.role_description; + + if(!state.authenticatedUser.isAdmin) { + response.writeHead(403, "Forbidden"); + response.end(); + return; + } + + // get the role + var role = sqlTiddlerDatabase.getRoleById(role_id); + + if(!role) { + response.writeHead(404, "Role not found"); + response.end(); + return; + } + + if(role.role_name.toLowerCase().includes("admin")) { + response.writeHead(400, "Admin role cannot be updated"); + response.end(); + return; + } + + try { + sqlTiddlerDatabase.updateRole( + role_id, + role_name, + role_description + ); + + response.writeHead(302, { "Location": "/admin/roles" }); + response.end(); + } catch(error) { + console.error("Error updating role:", error); + response.writeHead(500, "Internal Server Error"); + response.end(); + } +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/update-user-profile.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/update-user-profile.js new file mode 100644 index 00000000000..3cbc0669000 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/handlers/update-user-profile.js @@ -0,0 +1,75 @@ +/*\ +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) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/login/error", + text: "You must be logged in to update profiles" + })); + response.writeHead(302, { "Location": "/login" }); + response.end(); + return; + } + + var userId = state.data.userId; + var username = state.data.username; + var email = state.data.email; + var roleId = state.data.role; + var currentUserId = state.authenticatedUser.user_id; + + var hasPermission = ($tw.utils.parseInt(userId) === currentUserId) || state.authenticatedUser.isAdmin; + + if(!hasPermission) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/update-profile/" + userId + "/error", + text: "You don't have permission to update this profile" + })); + response.writeHead(302, { "Location": "/admin/users/" + userId }); + response.end(); + return; + } + + if(!state.authenticatedUser.isAdmin) { + var userRole = state.server.sqlTiddlerDatabase.getUserRoles(userId); + roleId = userRole.role_id; + } + + var result = state.server.sqlTiddlerDatabase.updateUser(userId, username, email, roleId); + + if(result.success) { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/update-profile/" + userId + "/success", + text: result.message + })); + } else { + $tw.mws.store.adminWiki.addTiddler(new $tw.Tiddler({ + title: "$:/temp/mws/update-profile/" + userId + "/error", + text: result.message + })); + } + + response.writeHead(302, { "Location": "/admin/users/" + userId }); + response.end(); +}; + +}()); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js new file mode 100644 index 00000000000..7210ed82c68 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/acl-middleware.js @@ -0,0 +1,107 @@ +/*\ +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 extensionRegex = /\.[A-Za-z0-9]{1,4}$/; + + var server = state.server, + sqlTiddlerDatabase = $tw.mws.store.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); + var isGetRequest = request.method === "GET"; + var hasAnonymousAccess = state.allowAnon ? (isGetRequest ? state.allowAnonReads : state.allowAnonWrites) : false; + var anonymousAccessConfigured = state.anonAccessConfigured; + var entity = sqlTiddlerDatabase.getEntityByName(entityType, decodedEntityName); + var isAdmin = state.authenticatedUser?.isAdmin; + + if(isAdmin) { + return; + } + + if(entity?.owner_id) { + if(state.authenticatedUser?.user_id && (state.authenticatedUser?.user_id !== entity.owner_id) || !state.authenticatedUser?.user_id && !hasAnonymousAccess) { + const hasPermission = state.authenticatedUser?.user_id ? + entityType === 'recipe' ? sqlTiddlerDatabase.hasRecipePermission(state.authenticatedUser?.user_id, decodedEntityName, isGetRequest ? 'READ' : 'WRITE') + : sqlTiddlerDatabase.hasBagPermission(state.authenticatedUser?.user_id, decodedEntityName, isGetRequest ? 'READ' : 'WRITE') + : false + if(!response.headersSent && !hasPermission) { + response.writeHead(403, "Forbidden"); + response.end(); + } + return; + } + } else { + // First, we need to check if anonymous access is allowed + if(!state.authenticatedUser?.user_id && (anonymousAccessConfigured && !hasAnonymousAccess)) { + if(!response.headersSent && !extensionRegex.test(request.url)) { + response.writeHead(401, "Unauthorized"); + response.end(); + } + return; + } else { + // 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 ACL permission + var hasPermission = request.method === "POST" || sqlTiddlerDatabase.checkACLPermission(state.authenticatedUser.user_id, entityType, decodedEntityName, permissionName, entity?.owner_id) + if(!hasPermission && !hasAnonymousAccess) { + if(!response.headersSent) { + response.writeHead(403, "Forbidden"); + response.end(); + } + return; + } + } + } +}; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/multipart-forms.js b/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/multipart-forms.js new file mode 100644 index 00000000000..6a46699fabb --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/routes/helpers/multipart-forms.js @@ -0,0 +1,100 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/routes/helpers/multipart-forms.js +type: application/javascript +module-type: library + +A function that handles an incoming multipart/form-data stream, streaming the data to temporary files +in the store/inbox folder. Once the data is received, it imports any tiddlers and invokes a callback. + +\*/ + +(function() { + +/* +Process an incoming new multipart/form-data stream. Options include: + +store - tiddler store +state - provided by server.js +response - provided by server.js +bag_name - name of bag to write to +callback - invoked as callback(err,results). Results is an array of titles of imported tiddlers +*/ +exports.processIncomingStream = function(options) { + const self = this; + const path = require("path"), + fs = require("fs"); + // Process the incoming data + const inboxName = $tw.utils.stringifyDate(new Date()); + const inboxPath = path.resolve(options.store.attachmentStore.storePath,"inbox",inboxName); + $tw.utils.createDirectory(inboxPath); + let fileStream = null; // Current file being written + let hash = null; // Accumulating hash of current part + let length = 0; // Accumulating length of current part + const parts = []; // Array of {name:, headers:, value:, hash:} and/or {name:, filename:, headers:, inboxFilename:, hash:} + options.state.streamMultipartData({ + cbPartStart: function(headers,name,filename) { + const part = { + name: name, + filename: filename, + headers: headers + }; + if(filename) { + const inboxFilename = (parts.length).toString(); + part.inboxFilename = path.resolve(inboxPath,inboxFilename); + fileStream = fs.createWriteStream(part.inboxFilename); + } else { + part.value = ""; + } + hash = new $tw.sjcl.hash.sha256(); + length = 0; + parts.push(part) + }, + cbPartChunk: function(chunk) { + if(fileStream) { + fileStream.write(chunk); + } else { + parts[parts.length - 1].value += chunk; + } + length = length + chunk.length; + hash.update(chunk); + }, + cbPartEnd: function() { + if(fileStream) { + fileStream.end(); + } + fileStream = null; + parts[parts.length - 1].hash = $tw.sjcl.codec.hex.fromBits(hash.finalize()).slice(0,64).toString(); + hash = null; + }, + cbFinished: function(err) { + if(err) { + return options.callback(err); + } else { + const partFile = parts.find(part => part.name === "file-to-upload" && !!part.filename); + if(!partFile) { + return state.sendResponse(400, {"Content-Type": "text/plain"},"Missing file to upload"); + } + const type = partFile.headers["content-type"]; + const tiddlerFields = { + title: partFile.filename, + type: type + }; + for(const part of parts) { + const tiddlerFieldPrefix = "tiddler-field-"; + if(part.name.startsWith(tiddlerFieldPrefix)) { + tiddlerFields[part.name.slice(tiddlerFieldPrefix.length)] = part.value.trim(); + } + } + options.store.saveBagTiddlerWithAttachment(tiddlerFields,options.bag_name,{ + filepath: partFile.inboxFilename, + type: type, + hash: partFile.hash + }); + $tw.utils.deleteDirectory(inboxPath); + options.callback(null,[tiddlerFields.title]); + } + } + }); +}; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/startup.js b/plugins/tiddlywiki/multiwikiserver/modules/startup.js new file mode 100644 index 00000000000..42686e889be --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/startup.js @@ -0,0 +1,58 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/startup.js +type: application/javascript +module-type: startup + +Multi wiki server initialisation + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +// Export name and synchronous status +exports.name = "multiwikiserver"; +exports.platforms = ["node"]; +exports.before = ["story"]; +exports.synchronous = true; + +exports.startup = function() { + const store = setupStore(); + $tw.mws = { + store: store, + serverManager: new ServerManager({ + store: store + }) + }; +} + +function setupStore() { + const path = require("path"); + // Create and initialise the attachment store and the tiddler store + const AttachmentStore = require("$:/plugins/tiddlywiki/multiwikiserver/store/attachments.js").AttachmentStore, + attachmentStore = new AttachmentStore({ + storePath: path.resolve($tw.boot.wikiPath,"store/") + }), + SqlTiddlerStore = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-store.js").SqlTiddlerStore, + store = new SqlTiddlerStore({ + databasePath: path.resolve($tw.boot.wikiPath,"store/database.sqlite"), + engine: $tw.wiki.getTiddlerText("$:/config/MultiWikiServer/Engine","better"), // better || wasm + attachmentStore: attachmentStore + }); + return store; +} + +function ServerManager(store) { + this.servers = []; +} + +ServerManager.prototype.createServer = function(options) { + const MWSServer = require("$:/plugins/tiddlywiki/multiwikiserver/mws-server.js").Server, + server = new MWSServer(options); + this.servers.push(server); + return server; +} + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/store/attachments.js b/plugins/tiddlywiki/multiwikiserver/modules/store/attachments.js new file mode 100644 index 00000000000..352f96a8386 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/store/attachments.js @@ -0,0 +1,181 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/store/attachments.js +type: application/javascript +module-type: library + +Class to handle the attachments in the filing system + +The store folder looks like this: + +store/ + inbox/ - files that are in the process of being uploaded via a multipart form upload + 202402282125432742/ + 0 + 1 + ... + ... + files/ - files that are the text content of large tiddlers + b7def178-79c4-4d88-b7a4-39763014a58b/ + data.jpg - the extension is provided for convenience when directly inspecting the file system + meta.json - contains: + { + "filename": "data.jpg", + "type": "video/mp4", + "uploaded": "2024021821224823" + } + database.sql - The database file (managed by sql-tiddler-database.js) + +\*/ + +(function() { + +/* +Class to handle an attachment store. Options include: + +storePath - path to the store +*/ +function AttachmentStore(options) { + options = options || {}; + this.storePath = options.storePath; +} + +/* +Check if an attachment name is valid +*/ +AttachmentStore.prototype.isValidAttachmentName = function(attachment_name) { + const re = new RegExp('^[a-f0-9]{64}$'); + return re.test(attachment_name); +}; + +/* +Saves an attachment to a file. Options include: + +text: text content (may be binary) +type: MIME type of content +reference: reference to use for debugging +_canonical_uri: canonical uri of the content +*/ +AttachmentStore.prototype.saveAttachment = function(options) { + const path = require("path"), + fs = require("fs"); + // Compute the content hash for naming the attachment + const contentHash = $tw.sjcl.codec.hex.fromBits($tw.sjcl.hash.sha256.hash(options.text)).slice(0,64).toString(); + // Choose the best file extension for the attachment given its type + const contentTypeInfo = $tw.config.contentTypeInfo[options.type] || $tw.config.contentTypeInfo["application/octet-stream"]; + // Creat the attachment directory + const attachmentPath = path.resolve(this.storePath,"files",contentHash); + $tw.utils.createDirectory(attachmentPath); + // Save the data file + const dataFilename = "data" + contentTypeInfo.extension; + fs.writeFileSync(path.resolve(attachmentPath,dataFilename),options.text,contentTypeInfo.encoding); + // Save the meta.json file + fs.writeFileSync(path.resolve(attachmentPath,"meta.json"),JSON.stringify({ + _canonical_uri: options._canonical_uri, + created: $tw.utils.stringifyDate(new Date()), + modified: $tw.utils.stringifyDate(new Date()), + contentHash: contentHash, + filename: dataFilename, + type: options.type + },null,4)); + return contentHash; +}; + +/* +Adopts an attachment file into the store +*/ +AttachmentStore.prototype.adoptAttachment = function(incomingFilepath,type,hash,_canonical_uri) { + const path = require("path"), + fs = require("fs"); + // Choose the best file extension for the attachment given its type + const contentTypeInfo = $tw.config.contentTypeInfo[type] || $tw.config.contentTypeInfo["application/octet-stream"]; + // Creat the attachment directory + const attachmentPath = path.resolve(this.storePath,"files",hash); + $tw.utils.createDirectory(attachmentPath); + // Rename the data file + const dataFilename = "data" + contentTypeInfo.extension, + dataFilepath = path.resolve(attachmentPath,dataFilename); + fs.renameSync(incomingFilepath,dataFilepath); + // Save the meta.json file + fs.writeFileSync(path.resolve(attachmentPath,"meta.json"),JSON.stringify({ + _canonical_uri: _canonical_uri, + created: $tw.utils.stringifyDate(new Date()), + modified: $tw.utils.stringifyDate(new Date()), + contentHash: hash, + filename: dataFilename, + type: type + },null,4)); + return hash; +}; + +/* +Get an attachment ready to stream. Returns null if there is an error or: +stream: filestream of file +type: type of file +*/ +AttachmentStore.prototype.getAttachmentStream = function(attachment_name) { + const path = require("path"), + fs = require("fs"); + // Check the attachment name + if(this.isValidAttachmentName(attachment_name)) { + // Construct the path to the attachment directory + const attachmentPath = path.resolve(this.storePath,"files",attachment_name); + // Read the meta.json file + const metaJsonPath = path.resolve(attachmentPath,"meta.json"); + if(fs.existsSync(metaJsonPath) && fs.statSync(metaJsonPath).isFile()) { + const meta = $tw.utils.parseJSONSafe(fs.readFileSync(metaJsonPath,"utf8"),function() {return null;}); + if(meta) { + const dataFilepath = path.resolve(attachmentPath,meta.filename); + // Check if the data file exists + if(fs.existsSync(dataFilepath) && fs.statSync(dataFilepath).isFile()) { + // Stream the file + return { + stream: fs.createReadStream(dataFilepath), + type: meta.type + }; + } + } + } + } + // An error occured + return null; +}; + +/* +Get the size of an attachment file given the contentHash. +Returns the size in bytes, or null if the file doesn't exist. +*/ +AttachmentStore.prototype.getAttachmentFileSize = function(contentHash) { + const path = require("path"), + fs = require("fs"); + // Construct the path to the attachment directory + const attachmentPath = path.resolve(this.storePath, "files", contentHash); + // Read the meta.json file + const metaJsonPath = path.resolve(attachmentPath, "meta.json"); + if(fs.existsSync(metaJsonPath) && fs.statSync(metaJsonPath).isFile()) { + const meta = $tw.utils.parseJSONSafe(fs.readFileSync(metaJsonPath, "utf8"), function() { return null; }); + if(meta) { + const dataFilepath = path.resolve(attachmentPath, meta.filename); + // Check if the data file exists and return its size + if(fs.existsSync(dataFilepath) && fs.statSync(dataFilepath).isFile()) { + return fs.statSync(dataFilepath).size; + } + } + } + // Return null if the file doesn't exist or there was an error + return null; +}; + +AttachmentStore.prototype.getAttachmentMetadata = function(attachmentBlob) { + const path = require("path"), + fs = require("fs"); + const attachmentPath = path.resolve(this.storePath, "files", attachmentBlob); + const metaJsonPath = path.resolve(attachmentPath, "meta.json"); + if(fs.existsSync(metaJsonPath)) { + const metadata = JSON.parse(fs.readFileSync(metaJsonPath, "utf8")); + return metadata; + } + return null; +}; +exports.AttachmentStore = AttachmentStore; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/store/sql-engine.js b/plugins/tiddlywiki/multiwikiserver/modules/store/sql-engine.js new file mode 100644 index 00000000000..00d15edf370 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/store/sql-engine.js @@ -0,0 +1,142 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/store/sql-engine.js +type: application/javascript +module-type: library + +Low level functions to work with the SQLite engine, either via better-sqlite3 or node-sqlite3-wasm. + +This class is intended to encapsulate all engine-specific logic. + +\*/ + +(function() { + +/* +Create a database engine. Options include: + +databasePath - path to the database file (can be ":memory:" or missing to get a temporary database) +engine - wasm | better +*/ +function SqlEngine(options) { + options = options || {}; + // Initialise transaction mechanism + this.transactionDepth = 0; + // Initialise the statement cache + this.statements = Object.create(null); // Hashmap by SQL text of statement objects + // Choose engine + this.engine = options.engine || "node"; // node | wasm | better + // Create the database file directories if needed + if(options.databasePath) { + $tw.utils.createFileDirectories(options.databasePath); + } + // Create the database + const databasePath = options.databasePath || ":memory:"; + let Database; + switch(this.engine) { + case "node": + ({ DatabaseSync: Database } = require("node:sqlite")); + break; + case "wasm": + ({ Database } = require("node-sqlite3-wasm")); + break; + case "better": + Database = require("better-sqlite3"); + break; + } + this.db = new Database(databasePath,{ + verbose: undefined && console.log + }); + // Turn on WAL mode for better-sqlite3 + if(this.engine === "better") { + // See https://github.com/WiseLibs/better-sqlite3/blob/master/docs/performance.md + this.db.pragma("journal_mode = WAL"); + } +} + +SqlEngine.prototype.close = function() { + for(const sql in this.statements) { + if(this.statements[sql].finalize) { + this.statements[sql].finalize(); + } + } + this.statements = Object.create(null); + this.db.close(); + this.db = undefined; +}; + +SqlEngine.prototype.normaliseParams = function(params) { + params = params || {}; + const result = Object.create(null); + for(const paramName in params) { + if(this.engine !== "wasm" && paramName.startsWith("$")) { + result[paramName.slice(1)] = params[paramName]; + } else { + result[paramName] = params[paramName]; + } + } + return result; +}; + +SqlEngine.prototype.prepareStatement = function(sql) { + if(!(sql in this.statements)) { + this.statements[sql] = this.db.prepare(sql); + } + return this.statements[sql]; +}; + +SqlEngine.prototype.runStatement = function(sql,params) { + params = this.normaliseParams(params); + const statement = this.prepareStatement(sql); + return statement.run(params); +}; + +SqlEngine.prototype.runStatementGet = function(sql,params) { + params = this.normaliseParams(params); + const statement = this.prepareStatement(sql); + return statement.get(params); +}; + +SqlEngine.prototype.runStatementGetAll = function(sql,params) { + params = this.normaliseParams(params); + const statement = this.prepareStatement(sql); + return statement.all(params); +}; + +SqlEngine.prototype.runStatements = function(sqlArray) { + for(const sql of sqlArray) { + this.runStatement(sql); + } +}; + +/* +Execute the given function in a transaction, committing if successful but rolling back if an error occurs. Returns whatever the given function returns. + +Calls to this function can be safely nested, but only the topmost call will actually take place in a transaction. + +TODO: better-sqlite3 provides its own transaction method which we should be using if available +*/ +SqlEngine.prototype.transaction = function(fn) { + const alreadyInTransaction = this.transactionDepth > 0; + this.transactionDepth++; + try { + if(alreadyInTransaction) { + return fn(); + } else { + this.runStatement(`BEGIN TRANSACTION`); + try { + var result = fn(); + this.runStatement(`COMMIT TRANSACTION`); + } catch(e) { + this.runStatement(`ROLLBACK TRANSACTION`); + throw(e); + } + return result; + } + } finally { + this.transactionDepth--; + } +}; + +exports.SqlEngine = SqlEngine; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/store/sql-tiddler-database.js b/plugins/tiddlywiki/multiwikiserver/modules/store/sql-tiddler-database.js new file mode 100644 index 00000000000..14f8641f4ce --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/store/sql-tiddler-database.js @@ -0,0 +1,1479 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-database.js +type: application/javascript +module-type: library + +Low level SQL functions to store and retrieve tiddlers in a SQLite database. + +This class is intended to encapsulate all the SQL queries used to access the database. +Validation is for the most part left to the caller + +\*/ + +(function() { + +/* +Create a tiddler store. Options include: + +databasePath - path to the database file (can be ":memory:" to get a temporary database) +engine - wasm | better +*/ +function SqlTiddlerDatabase(options) { + options = options || {}; + const SqlEngine = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-engine.js").SqlEngine; + this.engine = new SqlEngine({ + databasePath: options.databasePath, + engine: options.engine + }); + this.entityTypeToTableMap = { + bag: { + table: "bags", + column: "bag_name" + }, + recipe: { + table: "recipes", + column: "recipe_name" + } + }; +} + +SqlTiddlerDatabase.prototype.close = function() { + this.engine.close(); +}; + + +SqlTiddlerDatabase.prototype.transaction = function(fn) { + return this.engine.transaction(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 (session_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, + bag_name TEXT UNIQUE NOT NULL, + accesscontrol TEXT NOT NULL, + description TEXT NOT NULL + ) + `,` + -- Recipes have names... + CREATE TABLE IF NOT EXISTS recipes ( + recipe_id INTEGER PRIMARY KEY AUTOINCREMENT, + recipe_name TEXT UNIQUE NOT NULL, + description TEXT NOT NULL, + owner_id INTEGER, + FOREIGN KEY (owner_id) REFERENCES users(user_id) + ) + `,` + -- ...and recipes also have an ordered list of bags + CREATE TABLE IF NOT EXISTS recipe_bags ( + recipe_id INTEGER NOT NULL, + bag_id INTEGER NOT NULL, + position INTEGER NOT NULL, + FOREIGN KEY (recipe_id) REFERENCES recipes(recipe_id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (bag_id) REFERENCES bags(bag_id) ON UPDATE CASCADE ON DELETE CASCADE, + UNIQUE (recipe_id, bag_id) + ) + `,` + -- Tiddlers are contained in bags and have titles + CREATE TABLE IF NOT EXISTS tiddlers ( + tiddler_id INTEGER PRIMARY KEY AUTOINCREMENT, + bag_id INTEGER NOT NULL, + title TEXT NOT NULL, + is_deleted BOOLEAN NOT NULL, + attachment_blob TEXT, -- null or the name of an attachment blob + FOREIGN KEY (bag_id) REFERENCES bags(bag_id) ON UPDATE CASCADE ON DELETE CASCADE, + UNIQUE (bag_id, title) + ) + `,` + -- Tiddlers also have unordered lists of fields, each of which has a name and associated value + CREATE TABLE IF NOT EXISTS fields ( + tiddler_id INTEGER, + field_name TEXT NOT NULL, + field_value TEXT NOT NULL, + 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) + `]); +}; + +SqlTiddlerDatabase.prototype.listBags = function() { + const rows = this.engine.runStatementGetAll(` + SELECT bag_name, bag_id, accesscontrol, description + FROM bags + ORDER BY bag_name + `); + return rows; +}; + +/* +Create or update a bag +Returns the bag_id of the bag +*/ +SqlTiddlerDatabase.prototype.createBag = function(bag_name,description,accesscontrol) { + accesscontrol = accesscontrol || ""; + // Run the queries + var bag = this.engine.runStatement(` + INSERT OR IGNORE INTO bags (bag_name, accesscontrol, description) + VALUES ($bag_name, '', '') + `,{ + $bag_name: bag_name + }); + const updateBags = this.engine.runStatement(` + UPDATE bags + SET accesscontrol = $accesscontrol, + description = $description + WHERE bag_name = $bag_name + `,{ + $bag_name: bag_name, + $accesscontrol: accesscontrol, + $description: description + }); + return updateBags.lastInsertRowid; +}; + +/* +Returns array of {recipe_name:,recipe_id:,description:,bag_names: []} +*/ +SqlTiddlerDatabase.prototype.listRecipes = function() { + const rows = this.engine.runStatementGetAll(` + SELECT r.recipe_name, r.recipe_id, r.description, r.owner_id, b.bag_name, rb.position + FROM recipes AS r + JOIN recipe_bags AS rb ON rb.recipe_id = r.recipe_id + JOIN bags AS b ON rb.bag_id = b.bag_id + ORDER BY r.recipe_name, rb.position + `); + const results = []; + let currentRecipeName = null, currentRecipeIndex = -1; + for(const row of rows) { + if(row.recipe_name !== currentRecipeName) { + currentRecipeName = row.recipe_name; + currentRecipeIndex += 1; + results.push({ + recipe_name: row.recipe_name, + recipe_id: row.recipe_id, + description: row.description, + owner_id: row.owner_id, + bag_names: [] + }); + } + results[currentRecipeIndex].bag_names.push(row.bag_name); + } + return results; +}; + +/* +Create or update a recipe +Returns the recipe_id of the recipe +*/ +SqlTiddlerDatabase.prototype.createRecipe = function(recipe_name,bag_names,description) { + // Run the queries + this.engine.runStatement(` + -- Delete existing recipe_bags entries for this recipe + DELETE FROM recipe_bags WHERE recipe_id = (SELECT recipe_id FROM recipes WHERE recipe_name = $recipe_name) + `,{ + $recipe_name: recipe_name + }); + const updateRecipes = this.engine.runStatement(` + -- Create the entry in the recipes table if required + INSERT OR REPLACE INTO recipes (recipe_name, description) + VALUES ($recipe_name, $description) + `,{ + $recipe_name: recipe_name, + $description: description + }); + this.engine.runStatement(` + INSERT INTO recipe_bags (recipe_id, bag_id, position) + SELECT r.recipe_id, b.bag_id, j.key as position + FROM recipes r + JOIN bags b + INNER JOIN json_each($bag_names) AS j ON j.value = b.bag_name + WHERE r.recipe_name = $recipe_name + `,{ + $recipe_name: recipe_name, + $bag_names: JSON.stringify(bag_names) + }); + + return updateRecipes.lastInsertRowid; +}; + +/* +Assign a recipe to a user +*/ +SqlTiddlerDatabase.prototype.assignRecipeToUser = function(recipe_name,user_id) { + this.engine.runStatement(` + UPDATE recipes SET owner_id = $user_id WHERE recipe_name = $recipe_name + `,{ + $recipe_name: recipe_name, + $user_id: user_id + }); +}; + +/* +Returns {tiddler_id:} +*/ +SqlTiddlerDatabase.prototype.saveBagTiddler = function(tiddlerFields,bag_name,attachment_blob) { + attachment_blob = attachment_blob || null; + // Update the tiddlers table + var info = this.engine.runStatement(` + INSERT OR REPLACE INTO tiddlers (bag_id, title, is_deleted, attachment_blob) + VALUES ( + (SELECT bag_id FROM bags WHERE bag_name = $bag_name), + $title, + FALSE, + $attachment_blob + ) + `,{ + $title: tiddlerFields.title, + $attachment_blob: attachment_blob, + $bag_name: bag_name + }); + // Update the fields table + this.engine.runStatement(` + INSERT OR REPLACE INTO fields (tiddler_id, field_name, field_value) + SELECT + t.tiddler_id, + json_each.key AS field_name, + json_each.value AS field_value + FROM ( + SELECT tiddler_id + FROM tiddlers + WHERE bag_id = ( + SELECT bag_id + FROM bags + WHERE bag_name = $bag_name + ) AND title = $title + ) AS t + JOIN json_each($field_values) AS json_each + `,{ + $title: tiddlerFields.title, + $bag_name: bag_name, + $field_values: JSON.stringify(Object.assign({},tiddlerFields,{title: undefined})) + }); + return { + tiddler_id: info.lastInsertRowid + } +}; + +/* +Returns {tiddler_id:,bag_name:} or null if the recipe is empty +*/ +SqlTiddlerDatabase.prototype.saveRecipeTiddler = function(tiddlerFields,recipe_name,attachment_blob) { + // Find the topmost bag in the recipe + var row = this.engine.runStatementGet(` + SELECT b.bag_name + FROM bags AS b + JOIN ( + SELECT rb.bag_id + FROM recipe_bags AS rb + WHERE rb.recipe_id = ( + SELECT recipe_id + FROM recipes + WHERE recipe_name = $recipe_name + ) + ORDER BY rb.position DESC + LIMIT 1 + ) AS selected_bag + ON b.bag_id = selected_bag.bag_id + `,{ + $recipe_name: recipe_name + }); + if(!row) { + return null; + } + // Save the tiddler to the topmost bag + var info = this.saveBagTiddler(tiddlerFields,row.bag_name,attachment_blob); + return { + tiddler_id: info.tiddler_id, + bag_name: row.bag_name + }; +}; + +/* +Returns {tiddler_id:} of the delete marker +*/ +SqlTiddlerDatabase.prototype.deleteTiddler = function(title,bag_name) { + // Delete the fields of this tiddler + this.engine.runStatement(` + DELETE FROM fields + WHERE tiddler_id IN ( + SELECT t.tiddler_id + FROM tiddlers AS t + INNER JOIN bags AS b ON t.bag_id = b.bag_id + WHERE b.bag_name = $bag_name AND t.title = $title + ) + `,{ + $title: title, + $bag_name: bag_name + }); + // Mark the tiddler itself as deleted + const rowDeleteMarker = this.engine.runStatement(` + INSERT OR REPLACE INTO tiddlers (bag_id, title, is_deleted, attachment_blob) + VALUES ( + (SELECT bag_id FROM bags WHERE bag_name = $bag_name), + $title, + TRUE, + NULL + ) + `,{ + $title: title, + $bag_name: bag_name + }); + return {tiddler_id: rowDeleteMarker.lastInsertRowid}; +}; + +/* +returns {tiddler_id:,tiddler:,attachment_blob:} +*/ +SqlTiddlerDatabase.prototype.getBagTiddler = function(title,bag_name) { + const rowTiddler = this.engine.runStatementGet(` + SELECT t.tiddler_id, t.attachment_blob + FROM bags AS b + INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id + WHERE t.title = $title AND b.bag_name = $bag_name AND t.is_deleted = FALSE + `,{ + $title: title, + $bag_name: bag_name + }); + if(!rowTiddler) { + return null; + } + const rows = this.engine.runStatementGetAll(` + SELECT field_name, field_value, tiddler_id + FROM fields + WHERE tiddler_id = $tiddler_id + `,{ + $tiddler_id: rowTiddler.tiddler_id + }); + if(rows.length === 0) { + return null; + } else { + return { + tiddler_id: rows[0].tiddler_id, + attachment_blob: rowTiddler.attachment_blob, + tiddler: rows.reduce((accumulator,value) => { + accumulator[value["field_name"]] = value.field_value; + return accumulator; + },{title: title}) + }; + } +}; + +/* +Returns {bag_name:, tiddler: {fields}, tiddler_id:, attachment_blob:} +*/ +SqlTiddlerDatabase.prototype.getRecipeTiddler = function(title,recipe_name) { + const rowTiddlerId = this.engine.runStatementGet(` + SELECT t.tiddler_id, t.attachment_blob, b.bag_name + FROM bags AS b + INNER JOIN recipe_bags AS rb ON b.bag_id = rb.bag_id + INNER JOIN recipes AS r ON rb.recipe_id = r.recipe_id + INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id + WHERE r.recipe_name = $recipe_name + AND t.title = $title + AND t.is_deleted = FALSE + ORDER BY rb.position DESC + LIMIT 1 + `,{ + $title: title, + $recipe_name: recipe_name + }); + if(!rowTiddlerId) { + return null; + } + // Get the fields + const rows = this.engine.runStatementGetAll(` + SELECT field_name, field_value + FROM fields + WHERE tiddler_id = $tiddler_id + `,{ + $tiddler_id: rowTiddlerId.tiddler_id + }); + return { + bag_name: rowTiddlerId.bag_name, + tiddler_id: rowTiddlerId.tiddler_id, + attachment_blob: rowTiddlerId.attachment_blob, + tiddler: rows.reduce((accumulator,value) => { + accumulator[value["field_name"]] = value.field_value; + return accumulator; + },{title: title}) + }; +}; + +/* +Checks if a user has permission to access a recipe +*/ +SqlTiddlerDatabase.prototype.hasRecipePermission = function(userId, recipeName, permissionName) { + try { + // check if the user is the owner of the entity + const recipe = this.engine.runStatementGet(` + SELECT owner_id + FROM recipes + WHERE recipe_name = $recipe_name + `, { + $recipe_name: recipeName + }); + + if(!!recipe?.owner_id && recipe?.owner_id === userId) { + return true; + } else { + var permission = this.checkACLPermission(userId, "recipe", recipeName, permissionName, recipe?.owner_id) + return permission; + } + + } catch (error) { + console.error(error) + return false + } +}; + +/* +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 acl.*, permissions.permission_name + FROM acl + LEFT JOIN permissions ON acl.permission_id = permissions.permission_id + WHERE acl.entity_type = $entity_type + AND acl.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, permissionName, ownerId) { + try { + // if the entityName starts with "$:/", we'll assume its a system bag/recipe, then grant the user permission + if(entityName.startsWith("$:/")) { + return true; + } + + const aclRecords = this.getACLByName(entityType, entityName, true); + const aclRecord = aclRecords.find(record => record.permission_name === permissionName); + + // If no ACL record exists, return true for hasPermission + if ((!aclRecord && !ownerId && aclRecords.length === 0) || ((!!aclRecord && !!ownerId) && ownerId === userId)) { + return true; + } + + // If ACL record exists, check for user permission using the retrieved permission_id + const checkPermissionQuery = ` + SELECT * + 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 + }); + + let hasPermission = result !== undefined; + + return hasPermission; + + } catch (error) { + console.error(error); + return false + } +}; + +/** + * 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 entity by name +*/ +SqlTiddlerDatabase.prototype.getEntityByName = function(entityType, entityName) { + const entityInfo = this.entityTypeToTableMap[entityType]; + if (entityInfo) { + return this.engine.runStatementGet(`SELECT * FROM ${entityInfo.table} WHERE ${entityInfo.column} = $entity_name`, { + $entity_name: entityName + }); + } + return null; +} + +/* +Get the titles of the tiddlers in a bag. Returns an empty array for bags that do not exist +*/ +SqlTiddlerDatabase.prototype.getBagTiddlers = function(bag_name) { + const rows = this.engine.runStatementGetAll(` + SELECT DISTINCT title, tiddler_id + FROM tiddlers + WHERE bag_id IN ( + SELECT bag_id + FROM bags + WHERE bag_name = $bag_name + ) + AND tiddlers.is_deleted = FALSE + ORDER BY title ASC + `,{ + $bag_name: bag_name + }); + return rows; +}; + +/* +Get the tiddler_id of the newest tiddler in a bag. Returns null for bags that do not exist +*/ +SqlTiddlerDatabase.prototype.getBagLastTiddlerId = function(bag_name) { + const row = this.engine.runStatementGet(` + SELECT tiddler_id + FROM tiddlers + WHERE bag_id IN ( + SELECT bag_id + FROM bags + WHERE bag_name = $bag_name + ) + ORDER BY tiddler_id DESC + LIMIT 1 + `,{ + $bag_name: bag_name + }); + if(row) { + return row.tiddler_id; + } else { + return null; + } +}; + +/* +Get the metadata of the tiddlers in a recipe as an array [{title:,tiddler_id:,bag_name:,is_deleted:}], +sorted in ascending order of tiddler_id. + +Options include: + +limit: optional maximum number of results to return +last_known_tiddler_id: tiddler_id of the last known update. Only returns tiddlers that have been created, modified or deleted since +include_deleted: boolean, defaults to false + +Returns null for recipes that do not exist +*/ +SqlTiddlerDatabase.prototype.getRecipeTiddlers = function(recipe_name,options) { + options = options || {}; + // Get the recipe ID + const rowsCheckRecipe = this.engine.runStatementGet(` + SELECT recipe_id FROM recipes WHERE recipes.recipe_name = $recipe_name + `,{ + $recipe_name: recipe_name + }); + if(!rowsCheckRecipe) { + return null; + } + const recipe_id = rowsCheckRecipe.recipe_id; + // Compose the query to get the tiddlers + const params = { + $recipe_id: recipe_id + } + if(options.limit) { + params.$limit = options.limit.toString(); + } + if(options.last_known_tiddler_id) { + params.$last_known_tiddler_id = options.last_known_tiddler_id; + } + const rows = this.engine.runStatementGetAll(` + SELECT title, tiddler_id, is_deleted, bag_name + FROM ( + SELECT t.title, t.tiddler_id, t.is_deleted, b.bag_name, MAX(rb.position) AS position + FROM bags AS b + INNER JOIN recipe_bags AS rb ON b.bag_id = rb.bag_id + INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id + WHERE rb.recipe_id = $recipe_id + ${options.include_deleted ? "" : "AND t.is_deleted = FALSE"} + ${options.last_known_tiddler_id ? "AND tiddler_id > $last_known_tiddler_id" : ""} + GROUP BY t.title + ORDER BY t.title, tiddler_id DESC + ${options.limit ? "LIMIT $limit" : ""} + ) + `,params); + return rows; +}; + +/* +Get the tiddler_id of the newest tiddler in a recipe. Returns null for recipes that do not exist +*/ +SqlTiddlerDatabase.prototype.getRecipeLastTiddlerId = function(recipe_name) { + const row = this.engine.runStatementGet(` + SELECT t.title, t.tiddler_id, b.bag_name, MAX(rb.position) AS position + FROM bags AS b + INNER JOIN recipe_bags AS rb ON b.bag_id = rb.bag_id + INNER JOIN recipes AS r ON rb.recipe_id = r.recipe_id + INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id + WHERE r.recipe_name = $recipe_name + GROUP BY t.title + ORDER BY t.tiddler_id DESC + LIMIT 1 + `,{ + $recipe_name: recipe_name + }); + if(row) { + return row.tiddler_id; + } else { + return null; + } +}; + +SqlTiddlerDatabase.prototype.deleteAllTiddlersInBag = function(bag_name) { + // Delete the fields + this.engine.runStatement(` + DELETE FROM fields + WHERE tiddler_id IN ( + SELECT tiddler_id + FROM tiddlers + WHERE bag_id = (SELECT bag_id FROM bags WHERE bag_name = $bag_name) + AND is_deleted = FALSE + ) + `,{ + $bag_name: bag_name + }); + // Mark the tiddlers as deleted + this.engine.runStatement(` + UPDATE tiddlers + SET is_deleted = TRUE + WHERE bag_id = (SELECT bag_id FROM bags WHERE bag_name = $bag_name) + AND is_deleted = FALSE + `,{ + $bag_name: bag_name + }); +}; + +/* +Get the names of the bags in a recipe. Returns an empty array for recipes that do not exist +*/ +SqlTiddlerDatabase.prototype.getRecipeBags = function(recipe_name) { + const rows = this.engine.runStatementGetAll(` + SELECT bags.bag_name + FROM bags + JOIN ( + SELECT rb.bag_id, rb.position as position + FROM recipe_bags AS rb + JOIN recipes AS r ON rb.recipe_id = r.recipe_id + WHERE r.recipe_name = $recipe_name + ORDER BY rb.position + ) AS bag_priority ON bags.bag_id = bag_priority.bag_id + ORDER BY position + `,{ + $recipe_name: recipe_name + }); + return rows.map(value => value.bag_name); +}; + +/* +Get the attachment value of a bag, if any exist +*/ +SqlTiddlerDatabase.prototype.getBagTiddlerAttachmentBlob = function(title,bag_name) { + const row = this.engine.runStatementGet(` + SELECT t.attachment_blob + FROM bags AS b + INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id + WHERE t.title = $title AND b.bag_name = $bag_name AND t.is_deleted = FALSE + `, { + $title: title, + $bag_name: bag_name + }); + return row ? row.attachment_blob : null; +}; + +/* +Get the attachment value of a recipe, if any exist +*/ +SqlTiddlerDatabase.prototype.getRecipeTiddlerAttachmentBlob = function(title,recipe_name) { + const row = this.engine.runStatementGet(` + SELECT t.attachment_blob + FROM bags AS b + INNER JOIN recipe_bags AS rb ON b.bag_id = rb.bag_id + INNER JOIN recipes AS r ON rb.recipe_id = r.recipe_id + INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id + WHERE r.recipe_name = $recipe_name AND t.title = $title AND t.is_deleted = FALSE + ORDER BY rb.position DESC + LIMIT 1 + `, { + $title: title, + $recipe_name: recipe_name + }); + 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.getUserByEmail = function(email) { + return this.engine.runStatementGet(` + SELECT * FROM users WHERE email = $email + `, { + $email: email + }); +}; + +SqlTiddlerDatabase.prototype.listUsersByRoleId = function(roleId) { + return this.engine.runStatementGetAll(` + SELECT u.* + FROM users u + JOIN user_roles ur ON u.user_id = ur.user_id + WHERE ur.role_id = $roleId + ORDER BY u.username + `, { + $roleId: roleId + }); +}; + +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.createUserSession = function(userId, sessionId) { + const currentTimestamp = new Date().toISOString(); + 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 + }); +}; + +SqlTiddlerDatabase.prototype.deleteUserSessions = function(userId) { + this.engine.runStatement(` + DELETE FROM sessions + WHERE user_id = $userId + `, { + $userId: userId + }); +}; + +// Set the user as an admin +SqlTiddlerDatabase.prototype.setUserAdmin = function(userId) { + var admin = this.getRoleByName("ADMIN"); + if(admin) { + this.addRoleToUser(userId, admin.role_id); + } +}; + +// 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 DESC + `); +}; + +// 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.deleteUserRolesByRoleId = function(roleId) { + this.engine.runStatement(` + DELETE FROM user_roles + WHERE role_id = $roleId + `, { + $roleId: roleId + }); +}; + +SqlTiddlerDatabase.prototype.deleteUserRolesByUserId = function(userId) { + this.engine.runStatement(` + DELETE FROM user_roles + WHERE user_id = $userId + `, { + $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; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/store/sql-tiddler-store.js b/plugins/tiddlywiki/multiwikiserver/modules/store/sql-tiddler-store.js new file mode 100644 index 00000000000..bb32eba18d3 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/store/sql-tiddler-store.js @@ -0,0 +1,438 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-store.js +type: application/javascript +module-type: library + +Higher level functions to perform basic tiddler operations with a sqlite3 database. + +This class is largely a wrapper for the sql-tiddler-database.js class, adding the following functionality: + +* Validating requests (eg bag and recipe name constraints) +* Synchronising bag and recipe names to the admin wiki +* Handling large tiddlers as attachments + +\*/ + +(function() { + +/* +Create a tiddler store. Options include: + +databasePath - path to the database file (can be ":memory:" to get a temporary database) +adminWiki - reference to $tw.Wiki object used for configuration +attachmentStore - reference to associated attachment store +engine - wasm | better +*/ +function SqlTiddlerStore(options) { + options = options || {}; + this.attachmentStore = options.attachmentStore; + this.adminWiki = options.adminWiki || $tw.wiki; + this.eventListeners = {}; // Hashmap by type of array of event listener functions + this.eventOutstanding = {}; // Hashmap by type of boolean true of outstanding events + // Create the database + this.databasePath = options.databasePath || ":memory:"; + var SqlTiddlerDatabase = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-database.js").SqlTiddlerDatabase; + this.sqlTiddlerDatabase = new SqlTiddlerDatabase({ + databasePath: this.databasePath, + engine: options.engine + }); + this.sqlTiddlerDatabase.createTables(); +} + +SqlTiddlerStore.prototype.addEventListener = function(type,listener) { + this.eventListeners[type] = this.eventListeners[type] || []; + this.eventListeners[type].push(listener); +}; + +SqlTiddlerStore.prototype.removeEventListener = function(type,listener) { + const listeners = this.eventListeners[type]; + if(listeners) { + var p = listeners.indexOf(listener); + if(p !== -1) { + listeners.splice(p,1); + } + } +}; + +SqlTiddlerStore.prototype.dispatchEvent = function(type /*, args */) { + const self = this; + if(!this.eventOutstanding[type]) { + $tw.utils.nextTick(function() { + self.eventOutstanding[type] = false; + const args = Array.prototype.slice.call(arguments,1), + listeners = self.eventListeners[type]; + if(listeners) { + for(var p=0; p 256) { + return "Too long"; + } + // Removed ~ from this list temporarily + if(allowPrivilegedCharacters) { + if(!(/^[^\s\u00A0\x00-\x1F\x7F`!@#%^&*()+={}\[\];\'\"<>,\\\?]+$/g.test(name))) { + return "Invalid character(s)"; + } + } else { + if(!(/^[^\s\u00A0\x00-\x1F\x7F`!@#$%^&*()+={}\[\];:\'\"<>.,\/\\\?]+$/g.test(name))) { + return "Invalid character(s)"; + } + } + return null; +}; + +/* +Returns null if the argument is an array of valid bag/recipe names, or a string error message if not +*/ +SqlTiddlerStore.prototype.validateItemNames = function(names,allowPrivilegedCharacters) { + if(!$tw.utils.isArray(names)) { + return "Not a valid array"; + } + var errors = []; + for(const name of names) { + const result = this.validateItemName(name,allowPrivilegedCharacters); + if(result && errors.indexOf(result) === -1) { + errors.push(result); + } + } + if(errors.length === 0) { + return null; + } else { + return errors.join("\n"); + } +}; + +SqlTiddlerStore.prototype.close = function() { + this.sqlTiddlerDatabase.close(); + this.sqlTiddlerDatabase = undefined; +}; + +/* +Given tiddler fields, tiddler_id and a bag_name, return the tiddler fields after the following process: +- Apply the tiddler_id as the revision field +- Apply the bag_name as the bag field +*/ +SqlTiddlerStore.prototype.processOutgoingTiddler = function(tiddlerFields,tiddler_id,bag_name,attachment_blob) { + if(attachment_blob !== null) { + return $tw.utils.extend( + {}, + tiddlerFields, + { + text: undefined, + _canonical_uri: `/bags/${$tw.utils.encodeURIComponentExtended(bag_name)}/tiddlers/${$tw.utils.encodeURIComponentExtended(tiddlerFields.title)}/blob` + } + ); + } else { + return tiddlerFields; + } +}; + +/* +*/ +SqlTiddlerStore.prototype.processIncomingTiddler = function(tiddlerFields, existing_attachment_blob, existing_canonical_uri) { + let attachmentSizeLimit = $tw.utils.parseNumber(this.adminWiki.getTiddlerText("$:/config/MultiWikiServer/AttachmentSizeLimit")); + if(attachmentSizeLimit < 100 * 1024) { + attachmentSizeLimit = 100 * 1024; + } + const attachmentsEnabled = this.adminWiki.getTiddlerText("$:/config/MultiWikiServer/EnableAttachments", "yes") === "yes"; + const contentTypeInfo = $tw.config.contentTypeInfo[tiddlerFields.type || "text/vnd.tiddlywiki"]; + const isBinary = !!contentTypeInfo && contentTypeInfo.encoding === "base64"; + + let shouldProcessAttachment = tiddlerFields.text && tiddlerFields.text.length > attachmentSizeLimit; + + if(existing_attachment_blob) { + const fileSize = this.attachmentStore.getAttachmentFileSize(existing_attachment_blob); + if(fileSize <= attachmentSizeLimit) { + const existingAttachmentMeta = this.attachmentStore.getAttachmentMetadata(existing_attachment_blob); + const hasCanonicalField = !!tiddlerFields._canonical_uri; + const skipAttachment = hasCanonicalField && (tiddlerFields._canonical_uri === (existingAttachmentMeta ? existingAttachmentMeta._canonical_uri : existing_canonical_uri)); + shouldProcessAttachment = !skipAttachment; + } else { + shouldProcessAttachment = false; + } + } + + if(attachmentsEnabled && isBinary && shouldProcessAttachment) { + const attachment_blob = existing_attachment_blob || this.attachmentStore.saveAttachment({ + text: tiddlerFields.text, + type: tiddlerFields.type, + reference: tiddlerFields.title, + _canonical_uri: tiddlerFields._canonical_uri + }); + + if(tiddlerFields && tiddlerFields._canonical_uri) { + delete tiddlerFields._canonical_uri; + } + + return { + tiddlerFields: Object.assign({}, tiddlerFields, { text: undefined }), + attachment_blob: attachment_blob + }; + } else { + return { + tiddlerFields: tiddlerFields, + attachment_blob: existing_attachment_blob + }; + } +}; + +SqlTiddlerStore.prototype.saveTiddlersFromPath = function(tiddler_files_path,bag_name) { + var self = this; + this.sqlTiddlerDatabase.transaction(function() { + // Clear out the bag + self.deleteAllTiddlersInBag(bag_name); + // Get the tiddlers + var path = require("path"); + var tiddlersFromPath = $tw.loadTiddlersFromPath(path.resolve($tw.boot.corePath,$tw.config.editionsPath,tiddler_files_path)); + // Save the tiddlers + for(const tiddlersFromFile of tiddlersFromPath) { + for(const tiddler of tiddlersFromFile.tiddlers) { + self.saveBagTiddler(tiddler,bag_name,null); + } + } + }); + self.dispatchEvent("change"); +}; + +SqlTiddlerStore.prototype.listBags = function() { + return this.sqlTiddlerDatabase.listBags(); +}; + +/* +Options include: + +allowPrivilegedCharacters - allows "$", ":" and "/" to appear in recipe name +*/ +SqlTiddlerStore.prototype.createBag = function(bag_name,description,options) { + options = options || {}; + var self = this; + return this.sqlTiddlerDatabase.transaction(function() { + const validationBagName = self.validateItemName(bag_name,options.allowPrivilegedCharacters); + if(validationBagName) { + return {message: validationBagName}; + } + self.sqlTiddlerDatabase.createBag(bag_name,description); + self.dispatchEvent("change"); + return null; + }); +}; + +SqlTiddlerStore.prototype.listRecipes = function() { + return this.sqlTiddlerDatabase.listRecipes(); +}; + +/* +Returns null on success, or {message:} on error + +Options include: + +allowPrivilegedCharacters - allows "$", ":" and "/" to appear in recipe name +*/ +SqlTiddlerStore.prototype.createRecipe = function(recipe_name,bag_names,description,options) { + bag_names = bag_names || []; + description = description || ""; + options = options || {}; + const validationRecipeName = this.validateItemName(recipe_name,options.allowPrivilegedCharacters); + if(validationRecipeName) { + return {message: validationRecipeName}; + } + if(bag_names.length === 0) { + return {message: "Recipes must contain at least one bag"}; + } + var self = this; + return this.sqlTiddlerDatabase.transaction(function() { + self.sqlTiddlerDatabase.createRecipe(recipe_name,bag_names,description); + self.dispatchEvent("change"); + return null; + }); +}; + +/* +Returns {tiddler_id:} +*/ +SqlTiddlerStore.prototype.saveBagTiddler = function(incomingTiddlerFields,bag_name) { + let _canonical_uri; + const existing_attachment_blob = this.sqlTiddlerDatabase.getBagTiddlerAttachmentBlob(incomingTiddlerFields.title,bag_name) + if(existing_attachment_blob) { + _canonical_uri = `/bags/${$tw.utils.encodeURIComponentExtended(bag_name)}/tiddlers/${$tw.utils.encodeURIComponentExtended(incomingTiddlerFields.title)}/blob` + } + const {tiddlerFields, attachment_blob} = this.processIncomingTiddler(incomingTiddlerFields,existing_attachment_blob,_canonical_uri); + const result = this.sqlTiddlerDatabase.saveBagTiddler(tiddlerFields,bag_name,attachment_blob); + this.dispatchEvent("change"); + return result; +}; + +/* +Create a tiddler in a bag adopting the specified file as the attachment. The attachment file must be on the same disk as the attachment store +Options include: + +filepath - filepath to the attachment file +hash - string hash of the attachment file +type - content type of file as uploaded + +Returns {tiddler_id:} +*/ +SqlTiddlerStore.prototype.saveBagTiddlerWithAttachment = function(incomingTiddlerFields,bag_name,options) { + const attachment_blob = this.attachmentStore.adoptAttachment(options.filepath,options.type,options.hash,options._canonical_uri); + if(attachment_blob) { + const result = this.sqlTiddlerDatabase.saveBagTiddler(incomingTiddlerFields,bag_name,attachment_blob); + this.dispatchEvent("change"); + return result; + } else { + return null; + } +}; + +/* +Returns {tiddler_id:,bag_name:} +*/ +SqlTiddlerStore.prototype.saveRecipeTiddler = function(incomingTiddlerFields,recipe_name) { + const existing_attachment_blob = this.sqlTiddlerDatabase.getRecipeTiddlerAttachmentBlob(incomingTiddlerFields.title,recipe_name) + const {tiddlerFields, attachment_blob} = this.processIncomingTiddler(incomingTiddlerFields,existing_attachment_blob,incomingTiddlerFields._canonical_uri); + const result = this.sqlTiddlerDatabase.saveRecipeTiddler(tiddlerFields,recipe_name,attachment_blob); + this.dispatchEvent("change"); + return result; +}; + +SqlTiddlerStore.prototype.deleteTiddler = function(title,bag_name) { + const result = this.sqlTiddlerDatabase.deleteTiddler(title,bag_name); + this.dispatchEvent("change"); + return result; +}; + +/* +returns {tiddler_id:,tiddler:} +*/ +SqlTiddlerStore.prototype.getBagTiddler = function(title,bag_name) { + var tiddlerInfo = this.sqlTiddlerDatabase.getBagTiddler(title,bag_name); + if(tiddlerInfo) { + return Object.assign( + {}, + tiddlerInfo, + { + tiddler: this.processOutgoingTiddler(tiddlerInfo.tiddler,tiddlerInfo.tiddler_id,bag_name,tiddlerInfo.attachment_blob) + }); + } else { + return null; + } +}; + +/* +Get an attachment ready to stream. Returns null if there is an error or: +tiddler_id: revision of tiddler +stream: stream of file +type: type of file +Returns {tiddler_id:,bag_name:} +*/ +SqlTiddlerStore.prototype.getBagTiddlerStream = function(title,bag_name) { + const tiddlerInfo = this.sqlTiddlerDatabase.getBagTiddler(title,bag_name); + if(tiddlerInfo) { + if(tiddlerInfo.attachment_blob) { + return $tw.utils.extend( + {}, + this.attachmentStore.getAttachmentStream(tiddlerInfo.attachment_blob), + { + tiddler_id: tiddlerInfo.tiddler_id, + bag_name: bag_name + } + ); + } else { + const { Readable } = require('stream'); + const stream = new Readable(); + stream._read = function() { + // Push data + const type = tiddlerInfo.tiddler.type || "text/plain"; + stream.push(tiddlerInfo.tiddler.text || "",($tw.config.contentTypeInfo[type] ||{encoding: "utf8"}).encoding); + // Push null to indicate the end of the stream + stream.push(null); + }; + return { + tiddler_id: tiddlerInfo.tiddler_id, + bag_name: bag_name, + stream: stream, + type: tiddlerInfo.tiddler.type || "text/plain" + } + } + } else { + return null; + } +}; + +/* +Returns {bag_name:, tiddler: {fields}, tiddler_id:} +*/ +SqlTiddlerStore.prototype.getRecipeTiddler = function(title,recipe_name) { + var tiddlerInfo = this.sqlTiddlerDatabase.getRecipeTiddler(title,recipe_name); + if(tiddlerInfo) { + return Object.assign( + {}, + tiddlerInfo, + { + tiddler: this.processOutgoingTiddler(tiddlerInfo.tiddler,tiddlerInfo.tiddler_id,tiddlerInfo.bag_name,tiddlerInfo.attachment_blob) + }); + } else { + return null; + } +}; + +/* +Get the titles of the tiddlers in a bag. Returns an empty array for bags that do not exist +*/ +SqlTiddlerStore.prototype.getBagTiddlers = function(bag_name) { + return this.sqlTiddlerDatabase.getBagTiddlers(bag_name); +}; + +/* +Get the tiddler_id of the newest tiddler in a bag. Returns null for bags that do not exist +*/ +SqlTiddlerStore.prototype.getBagLastTiddlerId = function(bag_name) { + return this.sqlTiddlerDatabase.getBagLastTiddlerId(bag_name); +}; + +/* +Get the titles of the tiddlers in a recipe as {title:,bag_name:}. Returns null for recipes that do not exist +*/ +SqlTiddlerStore.prototype.getRecipeTiddlers = function(recipe_name,options) { + return this.sqlTiddlerDatabase.getRecipeTiddlers(recipe_name,options); +}; + +/* +Get the tiddler_id of the newest tiddler in a recipe. Returns null for recipes that do not exist +*/ +SqlTiddlerStore.prototype.getRecipeLastTiddlerId = function(recipe_name) { + return this.sqlTiddlerDatabase.getRecipeLastTiddlerId(recipe_name); +}; + +SqlTiddlerStore.prototype.deleteAllTiddlersInBag = function(bag_name) { + var self = this; + return this.sqlTiddlerDatabase.transaction(function() { + const result = self.sqlTiddlerDatabase.deleteAllTiddlersInBag(bag_name); + self.dispatchEvent("change"); + return result; + }); +}; + +/* +Get the names of the bags in a recipe. Returns an empty array for recipes that do not exist +*/ +SqlTiddlerStore.prototype.getRecipeBags = function(recipe_name) { + return this.sqlTiddlerDatabase.getRecipeBags(recipe_name); +}; + +exports.SqlTiddlerStore = SqlTiddlerStore; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/store/tests-sql-tiddler-database.js b/plugins/tiddlywiki/multiwikiserver/modules/store/tests-sql-tiddler-database.js new file mode 100644 index 00000000000..563f86bf517 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/store/tests-sql-tiddler-database.js @@ -0,0 +1,233 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/store/tests-sql-tiddler-database.js +type: application/javascript +tags: [[$:/tags/test-spec]] + +Tests the SQL tiddler database layer + +\*/ +if($tw.node) { +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +describe("SQL tiddler database with node built-in sqlite", function() { + runSqlDatabaseTests("node"); +}); + +describe("SQL tiddler database with node-sqlite3-wasm", function() { + runSqlDatabaseTests("wasm"); +}); + +describe("SQL tiddler database with better-sqlite3", function() { + runSqlDatabaseTests("better"); +}); + +function runSqlDatabaseTests(engine) { + // Create and initialise the tiddler store + var SqlTiddlerDatabase = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-database.js").SqlTiddlerDatabase; + const sqlTiddlerDatabase = new SqlTiddlerDatabase({ + engine: engine + }); + sqlTiddlerDatabase.createTables(); + // Tear down + afterAll(function() { + // Close the database + sqlTiddlerDatabase.close(); + }); + // Run tests + it("should save and retrieve tiddlers using engine: " + engine, function() { + // Create bags and recipes + expect(sqlTiddlerDatabase.createBag("bag-alpha","Bag alpha")).toEqual(1); + expect(sqlTiddlerDatabase.createBag("bag-beta","Bag beta")).toEqual(2); + expect(sqlTiddlerDatabase.createBag("bag-gamma","Bag gamma")).toEqual(3); + expect(sqlTiddlerDatabase.listBags()).toEqual([ + { bag_name: 'bag-alpha', bag_id: 1, accesscontrol: '', description: "Bag alpha" }, + { bag_name: 'bag-beta', bag_id: 2, accesscontrol: '', description: "Bag beta" }, + { bag_name: 'bag-gamma', bag_id: 3, accesscontrol: '', description: "Bag gamma" } + ]); + expect(sqlTiddlerDatabase.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(1); + expect(sqlTiddlerDatabase.createRecipe("recipe-sigma",["bag-alpha","bag-gamma"],"Recipe sigma")).toEqual(2); + expect(sqlTiddlerDatabase.createRecipe("recipe-tau",["bag-alpha"],"Recipe tau")).toEqual(3); + expect(sqlTiddlerDatabase.createRecipe("recipe-upsilon",["bag-alpha","bag-gamma","bag-beta"],"Recipe upsilon")).toEqual(4); + expect(sqlTiddlerDatabase.listRecipes()).toEqual([ + { recipe_name: 'recipe-rho', recipe_id: 1, bag_names: ["bag-alpha","bag-beta"], description: "Recipe rho", owner_id: null }, + { recipe_name: 'recipe-sigma', recipe_id: 2, bag_names: ["bag-alpha","bag-gamma"], description: "Recipe sigma", owner_id: null }, + { recipe_name: 'recipe-tau', recipe_id: 3, bag_names: ["bag-alpha"], description: "Recipe tau", owner_id: null }, + { recipe_name: 'recipe-upsilon', recipe_id: 4, bag_names: ["bag-alpha","bag-gamma","bag-beta"], description: "Recipe upsilon", owner_id: null } + ]); + expect(sqlTiddlerDatabase.getRecipeBags("recipe-rho")).toEqual(["bag-alpha","bag-beta"]); + expect(sqlTiddlerDatabase.getRecipeBags("recipe-sigma")).toEqual(["bag-alpha","bag-gamma"]); + expect(sqlTiddlerDatabase.getRecipeBags("recipe-tau")).toEqual(["bag-alpha"]); + expect(sqlTiddlerDatabase.getRecipeBags("recipe-upsilon")).toEqual(["bag-alpha","bag-gamma","bag-beta"]); + // Save tiddlers + expect(sqlTiddlerDatabase.saveBagTiddler({title: "Another Tiddler",text: "I'm in alpha",tags: "one two three"},"bag-alpha")).toEqual({ + tiddler_id: 1 + }); + expect(sqlTiddlerDatabase.saveBagTiddler({title: "Hello There",text: "I'm in alpha as well",tags: "one two three"},"bag-alpha")).toEqual({ + tiddler_id: 2 + }); + expect(sqlTiddlerDatabase.saveBagTiddler({title: "Hello There",text: "I'm in beta",tags: "four five six"},"bag-beta")).toEqual({ + tiddler_id: 3 + }); + expect(sqlTiddlerDatabase.saveBagTiddler({title: "Hello There",text: "I'm in gamma",tags: "seven eight nine"},"bag-gamma")).toEqual({ + tiddler_id: 4 + }); + // Verify what we've got + expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-rho")).toEqual([ + { title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 }, + { title: 'Hello There', tiddler_id: 3, bag_name: 'bag-beta', is_deleted: 0 } + ]); + expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-sigma")).toEqual([ + { title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 }, + { title: 'Hello There', tiddler_id: 4, bag_name: 'bag-gamma', is_deleted: 0 } + ]); + expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-rho").tiddler).toEqual({ title: "Hello There", text: "I'm in beta", tags: "four five six" }); + expect(sqlTiddlerDatabase.getRecipeTiddler("Missing Tiddler","recipe-rho")).toEqual(null); + expect(sqlTiddlerDatabase.getRecipeTiddler("Another Tiddler","recipe-rho").tiddler).toEqual({ title: "Another Tiddler", text: "I'm in alpha", tags: "one two three" }); + expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-sigma").tiddler).toEqual({ title: "Hello There", text: "I'm in gamma", tags: "seven eight nine" }); + expect(sqlTiddlerDatabase.getRecipeTiddler("Another Tiddler","recipe-sigma").tiddler).toEqual({ title: "Another Tiddler", text: "I'm in alpha", tags: "one two three" }); + expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-upsilon").tiddler).toEqual({title: "Hello There",text: "I'm in beta",tags: "four five six"}); + // Delete a tiddlers to ensure the underlying tiddler in the recipe shows through + sqlTiddlerDatabase.deleteTiddler("Hello There","bag-beta"); + expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-rho")).toEqual([ + { title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 }, + { title: 'Hello There', tiddler_id: 2, bag_name: 'bag-alpha', is_deleted: 0 } + ]); + expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-sigma")).toEqual([ + { title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 }, + { title: 'Hello There', tiddler_id: 4, bag_name: 'bag-gamma', is_deleted: 0 } + ]); + expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-beta")).toEqual(null); + sqlTiddlerDatabase.deleteTiddler("Another Tiddler","bag-alpha"); + expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-rho")).toEqual([ { title: 'Hello There', tiddler_id: 2, bag_name: 'bag-alpha', is_deleted: 0 } ]); + expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-sigma")).toEqual([ { title: 'Hello There', tiddler_id: 4, bag_name: 'bag-gamma', is_deleted: 0 } ]); + // Save a recipe tiddler + expect(sqlTiddlerDatabase.saveRecipeTiddler({title: "More", text: "None"},"recipe-rho")).toEqual({tiddler_id: 7, bag_name: 'bag-beta'}); + expect(sqlTiddlerDatabase.getRecipeTiddler("More","recipe-rho").tiddler).toEqual({title: "More", text: "None"}); + }); + + it("should manage users correctly", function() { + console.log("should manage users correctly") + // Create users + const userId1 = sqlTiddlerDatabase.createUser("john_doe", "john@example.com", "pass123"); + const userId2 = sqlTiddlerDatabase.createUser("jane_doe", "jane@example.com", "pass123"); + + // Retrieve users + const user1 = sqlTiddlerDatabase.getUser(userId1); + expect(user1.user_id).toBe(userId1); + expect(user1.username).toBe("john_doe"); + expect(user1.email).toBe("john@example.com"); + expect(user1.created_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); // Match timestamp format + expect(user1.last_login).toBeNull(); + + // Update user + sqlTiddlerDatabase.updateUser(userId1, "john_updated", "john_updated@example.com"); + expect(sqlTiddlerDatabase.getUser(userId1).username).toBe("john_updated"); + expect(sqlTiddlerDatabase.getUser(userId1).email).toBe("john_updated@example.com"); + + // List users + const users = sqlTiddlerDatabase.listUsers(); + expect(users.length).toBe(2); + expect(users[0].username).toBe("jane_doe"); + expect(users[1].username).toBe("john_updated"); + + // Delete user + sqlTiddlerDatabase.deleteUser(userId2); + // expect(sqlTiddlerDatabase.getUser(userId2)).toBe(null || undefined); + }); + + it("should manage groups correctly", function() { + console.log("should manage groups correctly") + // Create groups + const groupId1 = sqlTiddlerDatabase.createGroup("Editors", "Can edit content"); + const groupId2 = sqlTiddlerDatabase.createGroup("Viewers", "Can view content"); + + // Retrieve groups + expect(sqlTiddlerDatabase.getGroup(groupId1)).toEqual({ + group_id: groupId1, + group_name: "Editors", + description: "Can edit content" + }); + + // Update group + sqlTiddlerDatabase.updateGroup(groupId1, "Super Editors", "Can edit all content"); + expect(sqlTiddlerDatabase.getGroup(groupId1).group_name).toBe("Super Editors"); + expect(sqlTiddlerDatabase.getGroup(groupId1).description).toBe("Can edit all content"); + + // List groups + const groups = sqlTiddlerDatabase.listGroups(); + expect(groups.length).toBe(2); + expect(groups[0].group_name).toBe("Super Editors"); + expect(groups[1].group_name).toBe("Viewers"); + + // Delete group + sqlTiddlerDatabase.deleteGroup(groupId2); + // expect(sqlTiddlerDatabase.getGroup(groupId2)).toBe(null || undefined); + }); + + + it("should manage roles correctly", function() { + console.log("should manage roles correctly") + // Create roles + const roleId1 = sqlTiddlerDatabase.createRole("Admin" + Date.now(), "Full access"); + const roleId2 = sqlTiddlerDatabase.createRole("Editor" + Date.now(), "Can edit content"); + + // Retrieve roles + expect(sqlTiddlerDatabase.getRole(roleId1)).toEqual({ + role_id: roleId1, + role_name: jasmine.stringMatching(/^Admin\d+$/), + description: "Full access" + }); + + // Update role + sqlTiddlerDatabase.updateRole(roleId1, "Super Admin" + Date.now(), "God-like powers"); + expect(sqlTiddlerDatabase.getRole(roleId1).role_name).toMatch(/^Super Admin\d+$/); + expect(sqlTiddlerDatabase.getRole(roleId1).description).toBe("God-like powers"); + + // List roles + const roles = sqlTiddlerDatabase.listRoles(); + expect(roles.length).toBeGreaterThan(0); + // expect(roles[0].role_name).toMatch(/^Editor\d+$/); + // expect(roles[1].role_name).toMatch(/^Super Admin\d+$/); + + // Delete role + sqlTiddlerDatabase.deleteRole(roleId2); + // expect(sqlTiddlerDatabase.getRole(roleId2)).toBeUndefined(); + }); + + it("should manage permissions correctly", function() { + console.log("should manage permissions correctly") + // Create permissions + const permissionId1 = sqlTiddlerDatabase.createPermission("read_tiddlers" + Date.now(), "Can read tiddlers"); + const permissionId2 = sqlTiddlerDatabase.createPermission("write_tiddlers" + Date.now(), "Can write tiddlers"); + + // Retrieve permissions + expect(sqlTiddlerDatabase.getPermission(permissionId1)).toEqual({ + permission_id: permissionId1, + permission_name: jasmine.stringMatching(/^read_tiddlers\d+$/), + description: "Can read tiddlers" + }); + + // Update permission + sqlTiddlerDatabase.updatePermission(permissionId1, "read_all_tiddlers" + Date.now(), "Can read all tiddlers"); + expect(sqlTiddlerDatabase.getPermission(permissionId1).permission_name).toMatch(/^read_all_tiddlers\d+$/); + expect(sqlTiddlerDatabase.getPermission(permissionId1).description).toBe("Can read all tiddlers"); + + // List permissions + const permissions = sqlTiddlerDatabase.listPermissions(); + expect(permissions.length).toBeGreaterThan(0); + expect(permissions[0].permission_name).toMatch(/^read_all_tiddlers\d+$/); + expect(permissions[1].permission_name).toMatch(/^write_tiddlers\d+$/); + + // Delete permission + sqlTiddlerDatabase.deletePermission(permissionId2); + // expect(sqlTiddlerDatabase.getPermission(permissionId2)).toBeUndefined(); + }); +} + +})(); + +} diff --git a/plugins/tiddlywiki/multiwikiserver/modules/store/tests-sql-tiddler-store.js b/plugins/tiddlywiki/multiwikiserver/modules/store/tests-sql-tiddler-store.js new file mode 100644 index 00000000000..c5888b2ce16 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/store/tests-sql-tiddler-store.js @@ -0,0 +1,149 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/store/tests-sql-tiddler-store.js +type: application/javascript +tags: [[$:/tags/test-spec]] + +Tests the SQL tiddler store layer + +\*/ +if($tw.node) { +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +describe("SQL tiddler store with node-sqlite3-wasm", function() { + runSqlStoreTests("wasm"); +}); + +describe("SQL tiddler store with better-sqlite3", function() { + runSqlStoreTests("better"); +}); + +function runSqlStoreTests(engine) { + var SqlTiddlerStore = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-store.js").SqlTiddlerStore; + + var store; + + beforeEach(function() { + store = new SqlTiddlerStore({ + databasePath: ":memory:", + engine: engine + }); + }); + + afterEach(function() { + store.close(); + store = null; + }); + + it("should return empty results without failure on an empty store", function() { + expect(store.listBags()).toEqual([]); + expect(store.listRecipes()).toEqual([]); + }); + + it("should return a single bag after creating a bag", function() { + expect(store.createBag("bag-alpha", "Bag alpha")).toEqual(null); + expect(store.listBags()).toEqual([{ + bag_name: "bag-alpha", + bag_id: 1, + accesscontrol: "", + description: "Bag alpha" + }]); + }); + + it("should return empty results after failing to create a bag with an invalid name", function() { + expect(store.createBag("bag alpha", "Bag alpha")).toEqual({ + message: "Invalid character(s)" + }); + expect(store.listBags()).toEqual([]); + }); + + it("should return a bag with new description after re-creating", function() { + expect(store.createBag("bag-alpha", "Bag alpha")).toEqual(null); + expect(store.createBag("bag-alpha", "Different description")).toEqual(null); + expect(store.listBags()).toEqual([{ + bag_name: "bag-alpha", + bag_id: 1, + accesscontrol: "", + description: "Different description" + }]); + }); + + it("should return a saved tiddler within a bag", function() { + expect(store.createBag("bag-alpha", "Bag alpha")).toEqual(null); + var saveBagResult = store.saveBagTiddler({ + title: "Another Tiddler", + text: "I'm in alpha", + tags: "one two three" + }, "bag-alpha"); + + expect(new Set(Object.keys(saveBagResult))).toEqual(new Set(["tiddler_id"])); + expect(typeof(saveBagResult.tiddler_id)).toBe("number"); + + expect(store.getBagTiddlers("bag-alpha")).toEqual([{title: "Another Tiddler", tiddler_id: 1}]); + + var getBagTiddlerResult = store.getBagTiddler("Another Tiddler","bag-alpha"); + expect(typeof(getBagTiddlerResult.tiddler_id)).toBe("number"); + delete getBagTiddlerResult.tiddler_id; + expect(getBagTiddlerResult).toEqual({ attachment_blob: null, tiddler: {title: "Another Tiddler", text: "I'm in alpha", tags: "one two three"} }); + }); + + it("should return a single recipe after creating that recipe", function() { + expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null); + expect(store.createBag("bag-beta","Bag beta")).toEqual(null); + expect(store.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(null); + + expect(store.listRecipes()).toEqual([ + { recipe_name: "recipe-rho", recipe_id: 1, bag_names: ["bag-alpha","bag-beta"], description: "Recipe rho", owner_id: null } + ]); + }); + + it("should return a recipe's bags after creating that recipe", function() { + expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null); + expect(store.createBag("bag-beta","Bag beta")).toEqual(null); + expect(store.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(null); + + expect(store.getRecipeBags("recipe-rho")).toEqual(["bag-alpha","bag-beta"]); + }); + + it("should return a saved tiddler within a recipe", function() { + expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null); + expect(store.createBag("bag-beta","Bag beta")).toEqual(null); + expect(store.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(null); + + var saveRecipeResult = store.saveRecipeTiddler({ + title: "Another Tiddler", + text: "I'm in rho" + },"recipe-rho"); + + expect(new Set(Object.keys(saveRecipeResult))).toEqual(new Set(["tiddler_id", "bag_name"])); + expect(typeof(saveRecipeResult.tiddler_id)).toBe("number"); + expect(saveRecipeResult.bag_name).toBe("bag-beta"); + + expect(store.getRecipeTiddlers("recipe-rho")).toEqual([{title: "Another Tiddler", tiddler_id: 1, bag_name: "bag-beta", is_deleted: 0 }]); + + var getRecipeTiddlerResult = store.getRecipeTiddler("Another Tiddler","recipe-rho"); + expect(typeof(getRecipeTiddlerResult.tiddler_id)).toBe("number"); + delete getRecipeTiddlerResult.tiddler_id; + expect(getRecipeTiddlerResult).toEqual({ attachment_blob: null, bag_name: "bag-beta", tiddler: {title: "Another Tiddler", text: "I'm in rho"} }); + }); + + it("should return no tiddlers after the only one has been deleted", function() { + expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null); + + store.saveBagTiddler({ + title: "Another Tiddler", + text: "I'm in alpha", + tags: "one two three" + }, "bag-alpha"); + + store.deleteTiddler("Another Tiddler","bag-alpha"); + expect(store.getBagTiddlers("bag-alpha")).toEqual([]); + }); +} + +})(); + +} diff --git a/plugins/tiddlywiki/multiwikiserver/modules/tests/test-attachment.js b/plugins/tiddlywiki/multiwikiserver/modules/tests/test-attachment.js new file mode 100644 index 00000000000..9298c43f8a3 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/tests/test-attachment.js @@ -0,0 +1,183 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/modules/tests/test-attachment.js +type: application/javascript +tags: [[$:/tags/test-spec]] + +Tests attachments. + +\*/ +if(typeof window === 'undefined' && typeof process !== 'undefined' && process.versions && process.versions.node) { +(function(){ + var fs = require('fs'); + var path = require('path'); + var assert = require('assert'); + 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); + } + + 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; + + 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 }); + } + }); + }); + + 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('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('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('getAttachmentStream 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); + var stream = attachmentStore.getAttachmentStream(contentHash); + assert.notStrictEqual(stream, null); + assert.strictEqual(stream.type, 'application/octet-stream'); + } + }); + }); + } + })(); +})(); +} diff --git a/plugins/tiddlywiki/multiwikiserver/plugin.info b/plugins/tiddlywiki/multiwikiserver/plugin.info new file mode 100644 index 00000000000..d2a08583741 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/plugin.info @@ -0,0 +1,8 @@ +{ + "title": "$:/plugins/tiddlywiki/multiwikiserver", + "name": "Multi Wiki Server", + "description": "Multiple Wiki Server Extension", + "list": "readme", + "dependents": [], + "stability": "STABILITY_1_EXPERIMENTAL" +} diff --git a/plugins/tiddlywiki/multiwikiserver/scripts/admin-dropdown.js b/plugins/tiddlywiki/multiwikiserver/scripts/admin-dropdown.js new file mode 100644 index 00000000000..58336e65f12 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/scripts/admin-dropdown.js @@ -0,0 +1,19 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/scripts/admin-dropdown.js +type: application/javascript + +\*/ + +(function () { +document.addEventListener("click", function (event) { + var dropdown = document.querySelector(".mws-admin-dropdown-content"); + var dropbtn = document.querySelector(".mws-admin-dropbtn"); + if(!event.target.matches(".mws-admin-dropbtn")) { + if(dropdown.style.display === "block") { + dropdown.style.display = "none"; + } + } else { + dropdown.style.display = dropdown.style.display === "block" ? "none" : "block"; + } +}); +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/system-files/missing-favicon.png b/plugins/tiddlywiki/multiwikiserver/system-files/missing-favicon.png new file mode 100644 index 00000000000..242f48adea0 Binary files /dev/null and b/plugins/tiddlywiki/multiwikiserver/system-files/missing-favicon.png differ diff --git a/plugins/tiddlywiki/multiwikiserver/system-files/missing-favicon.png.meta b/plugins/tiddlywiki/multiwikiserver/system-files/missing-favicon.png.meta new file mode 100644 index 00000000000..737f9f5e152 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/system-files/missing-favicon.png.meta @@ -0,0 +1,3 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/system-files/missing-favicon.png +tags: $:/tags/MWS/SystemFile +type: image/png diff --git a/plugins/tiddlywiki/multiwikiserver/system-files/motovun-jack.jpg b/plugins/tiddlywiki/multiwikiserver/system-files/motovun-jack.jpg new file mode 100644 index 00000000000..5a593c4edf6 Binary files /dev/null and b/plugins/tiddlywiki/multiwikiserver/system-files/motovun-jack.jpg differ diff --git a/plugins/tiddlywiki/multiwikiserver/system-files/motovun-jack.jpg.meta b/plugins/tiddlywiki/multiwikiserver/system-files/motovun-jack.jpg.meta new file mode 100644 index 00000000000..02b5c3a94c6 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/system-files/motovun-jack.jpg.meta @@ -0,0 +1,3 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/system-files/motovun-jack.jpg +tags: $:/tags/MWS/SystemFile +type: image/jpeg diff --git a/plugins/tiddlywiki/multiwikiserver/system-files/mws-icon.png b/plugins/tiddlywiki/multiwikiserver/system-files/mws-icon.png new file mode 100644 index 00000000000..d7d7ca239d6 Binary files /dev/null and b/plugins/tiddlywiki/multiwikiserver/system-files/mws-icon.png differ diff --git a/plugins/tiddlywiki/multiwikiserver/system-files/mws-icon.png.meta b/plugins/tiddlywiki/multiwikiserver/system-files/mws-icon.png.meta new file mode 100644 index 00000000000..fb9c3e11b3f --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/system-files/mws-icon.png.meta @@ -0,0 +1,3 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/system-files/mws-icon.png +tags: $:/tags/MWS/SystemFile +type: image/png diff --git a/plugins/tiddlywiki/multiwikiserver/system-files/styles.css.tid b/plugins/tiddlywiki/multiwikiserver/system-files/styles.css.tid new file mode 100644 index 00000000000..2c84340e1c5 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/system-files/styles.css.tid @@ -0,0 +1,113 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/system-files/styles.css +tags: $:/tags/MWS/SystemFileWikified +system-file-type: text/css + +\import [subfilter{$:/core/config/GlobalImportFilter}] +\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock + +/* Import TiddlyWiki theme styles */ + +{{$:/core/ui/PageStylesheet}} + +/* MWS Styles */ + +body { + padding: 1rem; +} + +.mws-wiki-card { + display: flex; + margin: 1em 0; + width: 100%; + text-decoration: none; + color: <>; + background: <>; + border-radius: 0.28571429rem; + box-shadow: 0 1px 3px 0 #d4d4d5, 0 0 0 1px #d4d4d5; + padding: 0.5em 0.5em 0.5em 1em; +} + + +.mws-wiki-card:hover { + background: <>; + color: <>; +} + +.mws-wiki-card-image { + display: flex; + align-items: center; +} + +.mws-wiki-card-content { + padding-left: 1em; +} + +.mws-wiki-card-header { + font-size: 1.3em; + font-weight: bold; + margin: 0 0 0.25em 0; +} + +.mws-wiki-card-meta { + color: <>; +} + +.mws-wiki-card-description { + +} + +.mws-vertical-list { + list-style: none; + padding: 0; + line-height: 1.5; +} + +.mws-horizontal-list { + list-style: none; + padding: 0; +} + +.mws-horizontal-list > li { + display: inline-block; +} + +.mws-bag-pill { + background: <>; + color: <>; + fill: <>; + margin-right: 0.5em; + border-radius: 0.25em; + padding: 0 0.25em; +} + +.mws-bag-pill:hover { + background: <>; + color: <>; + fill: <>; +} + +.mws-bag-pill-topmost { + background: <>; +} + +.mws-bag-pill .mws-bag-pill-label { + margin-left: 0.5em; +} + +.mws-bag-pill-link { + text-decoration: none; + color: currentcolor; +} + +.mws-favicon { + object-fit: contain; + width: 4em; + max-height: 4em; +} + +.mws-favicon-small { + object-fit: contain; + vertical-align: text-bottom; + width: 1em; + max-height: 1em; +} \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/templates/add-user-form.tid b/plugins/tiddlywiki/multiwikiserver/templates/add-user-form.tid new file mode 100644 index 00000000000..fbb04ec72ba --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/add-user-form.tid @@ -0,0 +1,119 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/add-user-form + +

Add New User

+
+ <$vars username={{{ [[$:/temp/mws/queryParams]get[username]] }}} email={{{ [[$:/temp/mws/queryParams]get[email]] }}}> +
+ + > autocomplete="new-password" readonly> +
+
+ + > autocomplete="new-password" readonly> +
+ +
+ + +
+
+ + +
+ + <$list filter="[[$:/temp/mws/post-user/error]!is[missing]]" variable="errorTiddler"> +
+ <$text text={{{[get[text]]}}}/> +
+ + + <$list filter="[[$:/temp/mws/post-user/success]!is[missing]]" variable="successTiddler"> +
+ <$text text={{{[get[text]]}}}/> +
+ + +
+ <$button class="btn btn-primary"> + Add User + <> + <$action-sendmessage $message="tm-close-tiddler"/> + +
+
+ + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/anon-config-modal.tid b/plugins/tiddlywiki/multiwikiserver/templates/anon-config-modal.tid new file mode 100644 index 00000000000..8095fcd9b08 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/anon-config-modal.tid @@ -0,0 +1,37 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/anon-config-modal +subtitle: Anonymous Access Configuration +class: mws-modal + +
+
+

Anonymous Access Configuration

+

This configuration allows anonymous users to read and write to the wiki.

+
+
+ <$set name="isChecked" value={{{ [[$:/config/MultiWikiServer/AllowAnonymousReads]get[text]] }}}> + <$list filter="[match[yes]]"> + Allow anonymous reads + + <$list filter="[!match[yes]]"> + Allow anonymous reads + + +
+
+ <$set name="isChecked" value={{{ [[$:/config/MultiWikiServer/AllowAnonymousWrites]get[text]] }}}> + <$list filter="[match[yes]]"> + Allow anonymous writes + + <$list filter="[!match[yes]]"> + Allow anonymous writes + + +
+
+ +
+
+
+
\ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/templates/get-bag.tid b/plugins/tiddlywiki/multiwikiserver/templates/get-bag.tid new file mode 100644 index 00000000000..a48650c5239 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/get-bag.tid @@ -0,0 +1,45 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-bag + +! encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico?fallback=/.system/missing-favicon.png` + class="mws-favicon-small" + width="32px" +/> Bag <$text text={{{ []}}}/> + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/get-index.tid b/plugins/tiddlywiki/multiwikiserver/templates/get-index.tid new file mode 100644 index 00000000000..da81e2be94a --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/get-index.tid @@ -0,0 +1,408 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-index + + +\function .hide.system() +[match[on]] +[all[]!prefix[$:/]] +\end + +\procedure bagPill(element-tag:"span",is-topmost:"yes") + \whitespace trim + <$genesis $type=<> class={{{ mws-bag-pill [match[yes]then[mws-bag-pill-topmost]] +[join[ ]] }}}> + encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank"> + encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico?fallback=/.system/missing-favicon.png` + class="mws-favicon-small" + /> + + <$text text=<>/> + + + +\end + +<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header"> + <$set name="page-title" value="Wikis Available Here"> + <$transclude/> + + + +<$list filter="[match[yes]]"> +
+
+
⚠️
+
+ Warning: TiddlyWiki is currently running in anonymous access mode which allows anyone with access to the server to read and modify data. +
+ +
+
+ + +<$list filter="[match[yes]]"> + <$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/anon-config-modal"> + <$transclude/> + + + + +
+
+ Create a new recipe or modify and existing one +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +! Bags + +
    + <$list filter="[jsonindexes[]] :filter[jsonget,[bag_name].hide.system[]] :sort[jsonget,[bag_name]]" variable="bag-index" counter="counter"> +
  • + <$let + bag-info={{{ [jsonextract] }}} + bag-name={{{ [jsonget[bag_name]] }}} + > + <$transclude $variable="bagPill"/> + <$text text={{{ [jsonget[description]] }}}/> + +
  • + +
+ +
+
+ Create a new bag or modify and existing one +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +! Advanced + + +
+<%if [match[on]] %> + +<%else%> + +<%endif%> + + + + + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/get-users.tid b/plugins/tiddlywiki/multiwikiserver/templates/get-users.tid new file mode 100644 index 00000000000..0143f733804 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/get-users.tid @@ -0,0 +1,132 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-users + +\define edit-user-actions(user-id) + <$action-sendmessage $message="tm-modal" $param="$:/plugins/tiddlywiki/multiwikiserver/templates/edit-user-modal" user-id=<>/> +\end + +\define delete-user-actions(user-id) + <$action-sendmessage $message="tm-server-request" + method="DELETE" + url={{{ [[$:/admin/users/]addsuffix] }}} + redirectAfterSuccess="/admin/users"/> +\end + +<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header"> + <$set name="page-title" value="User Management"> + <$transclude/> + + + +
+ <$list filter="[jsonindexes[]count[]!match[0]]"> + + + + <$list filter="[match[yes]][match[yes]]"> +
+ <$transclude tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/add-user-form" mode="inline"/> +
+ +
+ + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/manage-acl.tid b/plugins/tiddlywiki/multiwikiserver/templates/manage-acl.tid new file mode 100644 index 00000000000..696e02f1e3e --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/manage-acl.tid @@ -0,0 +1,267 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/manage-acl + +<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header"> + <$set name="page-title" value="Manage ACL"> + <$transclude /> + + + + +
+

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

+
+
+

Add Recipe ACL Record

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

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

+
+
+

Add Bag ACL Record

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

Existing Roles

+ <$list filter="[jsonindexes[]]" variable="role-index"> + <$let role={{{ [jsonextract] }}}> +
+
+ + <$text text={{{ [jsonget[role_name]] }}}/> + + + <$text text={{{ [jsonget[description]] }}}/> + +
+ <$list filter="[jsonget[role_name]lowercase[]!match[admin]]" variable="ignore"> + + +
+ + +
+ + <$let edit-role-id={{{ [jsonget[role_id]] }}}> +
+ <$list filter="[!is[blank]]" variable="ignore"> +

Edit Role

+
addprefix[/admin/roles/]] }}} class="add-role-form"> + jsonget[role_name]] }}}/> + jsonget[description]] }}}/> + +
+ + <$list filter="[is[blank]]" variable="ignore"> +

Add New Role

+
+ + + <$list filter="[[$:/temp/mws/post-role/error]!is[missing]]" variable="errorTiddler"> +
+ <$text text={{{[get[text]]}}}/> +
+ + + <$list filter="[[$:/temp/mws/post-role/success]!is[missing]]" variable="successTiddler"> +
+ <$text text={{{[get[text]]}}}/> +
+ + +
+ +
+ +
+ + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account.tid b/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account.tid new file mode 100644 index 00000000000..53e28593a78 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account.tid @@ -0,0 +1,173 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account + + + + \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/templates/manage-user.tid b/plugins/tiddlywiki/multiwikiserver/templates/manage-user.tid new file mode 100644 index 00000000000..4940584157b --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/manage-user.tid @@ -0,0 +1,159 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/manage-user + +<$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/mws-header"> + <$set name="page-title" value="User Profile"> + <$transclude/> + + +
+ + + <% if [match[yes]] %> + <$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account"> + <$transclude/> + + <% elseif [match[yes]] %> + <$tiddler tiddler="$:/plugins/tiddlywiki/multiwikiserver/templates/manage-user-account"> + <$transclude/> + + <% endif %> + + <$let flash-message={{{ [[$:/state/mws/flash-message]get[text]] }}}> + <$reveal type="nomatch" state="$:/state/mws/flash-message" text=""> +
+ <$text text=<>/> +
+ <$action-setfield $tiddler="$:/state/mws/flash-message" text=""/> + + +
+ + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/mws-header.tid b/plugins/tiddlywiki/multiwikiserver/templates/mws-header.tid new file mode 100644 index 00000000000..93c6f7e0d9b --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/mws-header.tid @@ -0,0 +1,186 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/mws-header + +
+

+ + + + | + <$text text=<>/> +

+ +
+ + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/page.tid b/plugins/tiddlywiki/multiwikiserver/templates/page.tid new file mode 100644 index 00000000000..337f4865b67 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/page.tid @@ -0,0 +1,31 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/page + + +` + + + + + + + +` +<$view tiddler=<> field="text" format="htmlwikified" /> +` + + +` diff --git a/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers.tid b/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers.tid new file mode 100644 index 00000000000..9a24e2bf3b8 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers.tid @@ -0,0 +1,31 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers + +! <$image + source=`/bags/${ [encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico` + class="mws-favicon-small" + width="32px" +> + <$image + source="$:/plugins/multiwikiserver/images/missing-favicon.png" + class="mws-favicon-small" + width="32px" + /> + Bag <$text text={{{ []}}}/> + +

+Go back to Bag <$text text={{{ []}}}/> +

+ +

+The following tiddlers were successfully imported: +

+ + diff --git a/plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js b/plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js index b91ea356072..4ee05e20390 100644 --- a/plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js +++ b/plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js @@ -42,7 +42,8 @@ TiddlyWebAdaptor.prototype.getHost = function() { var text = this.wiki.getTiddlerText(CONFIG_HOST_TIDDLER,DEFAULT_HOST_TIDDLER), substitutions = [ {name: "protocol", value: document.location.protocol}, - {name: "host", value: document.location.host} + {name: "host", value: document.location.host}, + {name: "pathname", value: document.location.pathname} ]; for(var t=0; t