From b0a67300cc7ee779d32feae72e41e1387ab5d3bf Mon Sep 17 00:00:00 2001 From: Jeremy Ruston Date: Wed, 24 Jan 2024 22:24:24 +0000 Subject: [PATCH] Add support for bag descriptions, validate bags and recipes, and complete the UI for creating bags and recipes Styling to come --- .../MultiWikiServer Administration.tid | 225 +++++++++++------- .../multiwikiserver/modules/init.js | 10 +- .../multiwikiserver/modules/route-put-bag.js | 21 +- .../modules/route-put-recipe.js | 22 +- .../modules/sql-tiddler-database.js | 18 +- .../modules/sql-tiddler-store.js | 67 +++++- 6 files changed, 254 insertions(+), 109 deletions(-) diff --git a/plugins/tiddlywiki/multiwikiserver/admin-ui/MultiWikiServer Administration.tid b/plugins/tiddlywiki/multiwikiserver/admin-ui/MultiWikiServer Administration.tid index e0ec06329..309dc29c3 100644 --- a/plugins/tiddlywiki/multiwikiserver/admin-ui/MultiWikiServer Administration.tid +++ b/plugins/tiddlywiki/multiwikiserver/admin-ui/MultiWikiServer Administration.tid @@ -1,107 +1,169 @@ title: MultiWikiServer Administration -\procedure createBag(name) - -\procedure completion-createBag() -\import [subfilter{$:/core/config/GlobalImportFilter}] - <$action-log msg="In completion-createBag"/> - <$action-log/> -\end completion-createBag - -<$action-sendmessage - $message="tm-http-request" - url=`/wiki/$(name)$/bags/$(name)$` - method="PUT" - oncompletion=<> -/> +\procedure createBag(name,description) + \procedure completion-createBag() + \import [subfilter{$:/core/config/GlobalImportFilter}] + <$action-log msg="In completion-createBag"/> + <$action-log/> + \end completion-createBag + <$action-sendmessage + $message="tm-http-request" + url=`/wiki/$(name)$/bags/$(name)$` + method="PUT" + body=`{"description":"${ [encodeuricomponent[]] }$"}` + oncompletion=<> + /> \end createBag \procedure createBagButton(name) -<$button class=""> -<$transclude $variable="createBag" name={{$:/state/NewBagName}}/> -{{$:/core/images/new-button}} -<$text text="Create a new bag:"/><$edit-text tiddler="$:/state/NewBagName" tag="input"/> + \whitespace trim +
+
+ <$text text="Create a new bag"/> +
+
+
+ + <$edit-text tiddler="$:/state/NewBagName" tag="input" placeholder="(bag name)" class="mws-form-field-input"/> +
+
+ + <$edit-text tiddler="$:/state/NewBagDescription" tag="input" placeholder="(description)" class="mws-form-field-input"/> +
+
+
+ <$button class="mws-form-button"> + <$transclude + $variable="createBag" + name={{$:/state/NewBagName}} + description={{$:/state/NewBagDescription}} + /> + Create Bag + +
+
\end createBagButton -\procedure createRecipe(name) - -\procedure completion-createRecipe() -\import [subfilter{$:/core/config/GlobalImportFilter}] - <$action-log msg="In completion-createRecipe"/> - <$action-log/> -\end completion-createRecipe - -<$action-sendmessage - $message="tm-http-request" - url=`/wiki/$(name)$/recipes/$(name)$` - method="PUT" - oncompletion=<> -/> +\procedure createRecipe(name,bag_names,description) + \procedure completion-createRecipe() + \import [subfilter{$:/core/config/GlobalImportFilter}] + <$action-log msg="In completion-createRecipe"/> + <$action-log/> + \end completion-createRecipe + \procedure emptyArray() [] + \function createRecipeJson() + [enlist-input[]] :reduce[!match[]elsejsonset,] + \end createRecipeJson + <$action-log message="Sending" body=<>/> + <$action-sendmessage + $message="tm-http-request" + url=`/wiki/$(name)$/recipes/$(name)$` + method="PUT" + body=`{"bag_names":${ [] }$,"description":"${ [encodeuricomponent[]] }$"}` + oncompletion=<> + /> \end createRecipe -\procedure createRecipeButton(name) -<$button class=""> -<$transclude $variable="createRecipe" name={{$:/state/NewRecipeName}}/> -{{$:/core/images/new-button}} -<$text text="Create a new recipe:"/><$edit-text tiddler="$:/state/NewRecipeName" tag="input"/> +\procedure createRecipeButton() + \whitespace trim +
+
+ <$text text="Create a new recipe"/> +
+
+
+ + <$edit-text tiddler="$:/state/NewRecipeName" tag="input" placeholder="(recipe name)" class="mws-form-field-input"/> +
+
+ + <$edit-text tiddler="$:/state/NewRecipeBagNames" tag="input" placeholder="(space separated list of bags)"/> +
+
+ + <$edit-text tiddler="$:/state/NewRecipeDescription" tag="input" placeholder="(description)" class="mws-form-field-input"/> +
+
+
+ <$button class="mws-form-button"> + <$transclude + $variable="createRecipe" + name={{$:/state/NewRecipeName}} + bag_names={{$:/state/NewRecipeBagNames}} + description={{$:/state/NewRecipeDescription}} + /> + Create Recipe + +
+
\end createRecipeButton \procedure bagPill(element-tag:"span",is-topmost:"no") -\whitespace trim -<$genesis $type=<> class={{{ mws-bag-pill [match[yes]then[mws-bag-pill-topmost]] +[join[ ]] }}}> - - <$image - source=`/wiki/${ [{!!bag-name}encodeuricomponent[]] }$/bags/${ [{!!bag-name}encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico` - class="mws-favicon-small" - > + \whitespace trim + <$genesis $type=<> class={{{ mws-bag-pill [match[yes]then[mws-bag-pill-topmost]] +[join[ ]] }}}> + <$image - source="$:/plugins/multiwikiserver/images/missing-favicon.png" + source=`/wiki/${ [{!!bag-name}encodeuricomponent[]] }$/bags/${ [{!!bag-name}encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico` class="mws-favicon-small" - /> - - - <$text text={{!!bag-name}}/> - - - + > + <$image + source="$:/plugins/multiwikiserver/images/missing-favicon.png" + class="mws-favicon-small" + /> + + + <$text text={{!!bag-name}}/> + + + \end \procedure wikiCard() -\whitespace trim - -
- <$image - source=`/wiki/${ [{!!recipe-name}encodeuricomponent[]] }$/recipes/${ [{!!recipe-name}encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico` - class="mws-favicon" - > + \whitespace trim + +
<$image - source="$:/plugins/multiwikiserver/images/missing-favicon.png" + source=`/wiki/${ [{!!recipe-name}encodeuricomponent[]] }$/recipes/${ [{!!recipe-name}encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico` class="mws-favicon" - /> - -
-
-
- <$text text={{!!recipe-name}}/> + > + <$image + source="$:/plugins/multiwikiserver/images/missing-favicon.png" + class="mws-favicon" + /> +
-
- <%if [list] %> -
    - <$list filter="[list]" counter="counter"> - <$transclude $variable="bagPill" is-topmost={{{ [match[yes]] }}} element-tag="li"/> - -
- <%else%> - (no bags defined) - <%endif%> +
+
+ <$text text={{!!recipe-name}}/> +
+
+ <%if [list] %> +
    + <$list filter="[list]" counter="counter"> + <$transclude $variable="bagPill" is-topmost={{{ [match[yes]] }}} element-tag="li"/> + +
+ <%else%> + (no bags defined) + <%endif%> +
+
+ <$text text={{!!text}}/> +
-
- <$text text={{!!text}}/> -
-
-
+ \end
@@ -127,6 +189,7 @@ title: MultiWikiServer Administration <$list filter="[prefix[$:/state/MultiWikiServer/bags/]]">
  • <> + <$text text={{!!text}}/>
  • diff --git a/plugins/tiddlywiki/multiwikiserver/modules/init.js b/plugins/tiddlywiki/multiwikiserver/modules/init.js index edf5af985..7e880bcf6 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/init.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/init.js @@ -42,16 +42,16 @@ exports.startup = function() { databasePath: databasePath }); // Create docs bag and recipe - $tw.sqlTiddlerStore.createBag("docs"); + $tw.sqlTiddlerStore.createBag("docs","TiddlyWiki Documentation from https://tiddlywiki.com/"); $tw.sqlTiddlerStore.createRecipe("docs",["docs"],"TiddlyWiki Documentation from https://tiddlywiki.com/"); $tw.sqlTiddlerStore.saveTiddlersFromPath(path.resolve($tw.boot.corePath,$tw.config.editionsPath,"tw5.com/tiddlers"),"docs"); - $tw.sqlTiddlerStore.createBag("dev-docs"); + $tw.sqlTiddlerStore.createBag("dev-docs","TiddlyWiki Developer Documentation from https://tiddlywiki.com/dev/"); $tw.sqlTiddlerStore.createRecipe("dev-docs",["dev-docs"],"TiddlyWiki Developer Documentation from https://tiddlywiki.com/dev/"); $tw.sqlTiddlerStore.saveTiddlersFromPath(path.resolve($tw.boot.corePath,$tw.config.editionsPath,"dev/tiddlers"),"dev-docs"); // Create bags and recipes - $tw.sqlTiddlerStore.createBag("bag-alpha"); - $tw.sqlTiddlerStore.createBag("bag-beta"); - $tw.sqlTiddlerStore.createBag("bag-gamma"); + $tw.sqlTiddlerStore.createBag("bag-alpha","A test bag"); + $tw.sqlTiddlerStore.createBag("bag-beta","Another test bag"); + $tw.sqlTiddlerStore.createBag("bag-gamma","A further test bag"); $tw.sqlTiddlerStore.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"First wiki"); $tw.sqlTiddlerStore.createRecipe("recipe-sigma",["bag-alpha","bag-gamma"],"Second Wiki"); $tw.sqlTiddlerStore.createRecipe("recipe-tau",["bag-alpha"],"Third Wiki"); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-put-bag.js b/plugins/tiddlywiki/multiwikiserver/modules/route-put-bag.js index 7a4ad94c5..817a2ea96 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-put-bag.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-put-bag.js @@ -21,12 +21,21 @@ exports.path = /^\/wiki\/([^\/]+)\/bags\/([^\/]+)$/; exports.handler = function(request,response,state) { // Get the parameters var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]), - bag_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]); - if(bag_name === bag_name_2) { - $tw.sqlTiddlerStore.createBag(bag_name); - state.sendResponse(204,{ - "Content-Type": "text/plain" - }); + bag_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]), + data = $tw.utils.parseJSONSafe(state.data); + if(bag_name === bag_name_2 && data) { + const result = $tw.sqlTiddlerStore.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 { response.writeHead(404); response.end(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe.js b/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe.js index 7793bcf5b..ebacc2ba7 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe.js @@ -21,12 +21,22 @@ exports.path = /^\/wiki\/([^\/]+)\/recipes\/([^\/]+)$/; exports.handler = function(request,response,state) { // Get the parameters var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]), - recipe_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]); - if(recipe_name === recipe_name_2) { - $tw.sqlTiddlerStore.createRecipe(recipe_name); - state.sendResponse(204,{ - "Content-Type": "text/plain" - }); + recipe_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]), + data = $tw.utils.parseJSONSafe(state.data); + if(recipe_name === recipe_name_2 && data) { + const result = $tw.sqlTiddlerStore.createRecipe(recipe_name,data.bag_names,data.description); + console.log(`create recipe route handler for ${recipe_name} with ${JSON.stringify(data)} got result ${JSON.stringify(result)}`) + if(!result) { + state.sendResponse(204,{ + "Content-Type": "text/plain" + }); + } else { + state.sendResponse(400,{ + "Content-Type": "text/plain" + }, + result.message, + "utf8"); + } } else { response.writeHead(404); response.end(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-database.js b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-database.js index 520effda1..bef681376 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-database.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-database.js @@ -6,6 +6,7 @@ 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 \*/ @@ -58,7 +59,8 @@ SqlTiddlerDatabase.prototype.createTables = function() { CREATE TABLE IF NOT EXISTS bags ( bag_id INTEGER PRIMARY KEY AUTOINCREMENT, bag_name TEXT UNIQUE, - accesscontrol TEXT + accesscontrol TEXT, + description TEXT ) `,` -- Recipes have names... @@ -115,28 +117,30 @@ SqlTiddlerDatabase.prototype.logTables = function() { SqlTiddlerDatabase.prototype.listBags = function() { const rows = this.runStatementGetAll(` - SELECT bag_name, accesscontrol + SELECT bag_name, accesscontrol, description FROM bags ORDER BY bag_name `); return rows; }; -SqlTiddlerDatabase.prototype.createBag = function(bagname) { +SqlTiddlerDatabase.prototype.createBag = function(bagname,description) { // Run the queries this.runStatement(` - INSERT OR IGNORE INTO bags (bag_name, accesscontrol) - VALUES ($bag_name, '') + INSERT OR IGNORE INTO bags (bag_name, accesscontrol, description) + VALUES ($bag_name, '', '') `,{ bag_name: bagname }); this.runStatement(` UPDATE bags - SET accesscontrol = $accesscontrol + SET accesscontrol = $accesscontrol, + description = $description WHERE bag_name = $bag_name `,{ bag_name: bagname, - accesscontrol: "[some access control stuff]" + accesscontrol: "[some access control stuff]", + description: description }); }; diff --git a/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js index 0581e520f..80dffda23 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js @@ -7,6 +7,7 @@ Higher level functions to perform basic tiddler operations with a sqlite3 databa 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 _canonical_uri tiddlers @@ -34,6 +35,43 @@ function SqlTiddlerStore(options) { this.updateAdminWiki(); } +/* +Returns null if a bag/recipe name is valid, or a string error message if not +*/ +SqlTiddlerStore.prototype.validateItemName = function(name) { + if(typeof name !== "string") { + return "Not a valid string"; + } + if(name.length > 256) { + return "Too long"; + } + 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) { + if(!$tw.utils.isArray(names)) { + return "Not a valid array"; + } + var errors = []; + for(const name of names) { + const result = this.validateItemName(name); + if(result) { + errors.push(result); + } + } + if(errors.length === 0) { + return null; + } else { + return errors.join("\n"); + } +}; + SqlTiddlerStore.prototype.close = function() { this.sqlTiddlerDatabase.close(); this.sqlTiddlerDatabase = undefined; @@ -49,7 +87,7 @@ SqlTiddlerStore.prototype.updateAdminWiki = function() { this.saveEntityStateTiddler({ title: "bags/" + bagInfo.bag_name, "bag-name": bagInfo.bag_name, - text: "" + text: bagInfo.description }); } // Update recipes @@ -112,22 +150,42 @@ SqlTiddlerStore.prototype.listBags = function() { return this.sqlTiddlerDatabase.listBags(); }; -SqlTiddlerStore.prototype.createBag = function(bagname) { - this.sqlTiddlerDatabase.createBag(bagname); +SqlTiddlerStore.prototype.createBag = function(bagname,description) { + console.log(`create bag method for ${bagname} with ${description}`) + console.log(`validation results are ${this.validateItemName(bagname)}`) + const validationBagName = this.validateItemName(bagname); + if(validationBagName) { + return {message: validationBagName}; + } + this.sqlTiddlerDatabase.createBag(bagname,description); this.saveEntityStateTiddler({ title: "bags/" + bagname, "bag-name": bagname, - text: "" + text: description }); + return null; }; SqlTiddlerStore.prototype.listRecipes = function() { return this.sqlTiddlerDatabase.listRecipes(); }; +/* +Returns null on success, or {message:} on error +*/ SqlTiddlerStore.prototype.createRecipe = function(recipename,bagnames,description) { + console.log(`create recipe method for ${recipename} with ${JSON.stringify(bagnames)}`) + console.log(`validation results are ${this.validateItemName(recipename)} and ${this.validateItemNames(bagnames)}`) bagnames = bagnames || []; description = description || ""; + const validationRecipeName = this.validateItemName(recipename); + if(validationRecipeName) { + return {message: validationRecipeName}; + } + const validationBagNames = this.validateItemNames(bagnames); + if(validationBagNames) { + return {message: validationBagNames}; + } this.sqlTiddlerDatabase.createRecipe(recipename,bagnames,description); this.saveEntityStateTiddler({ title: "recipes/" + recipename, @@ -137,6 +195,7 @@ SqlTiddlerStore.prototype.createRecipe = function(recipename,bagnames,descriptio return this.entityStateTiddlerPrefix + "bags/" + bag_name; })) }); + return null; }; /*