diff --git a/core/modules/server/server.js b/core/modules/server/server.js index 6563f040b..4c782ef24 100644 --- a/core/modules/server/server.js +++ b/core/modules/server/server.js @@ -27,7 +27,6 @@ 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 - verbose - boolean */ function Server(options) { var self = this; @@ -35,7 +34,6 @@ function Server(options) { this.authenticators = options.authenticators || []; this.wiki = options.wiki; this.boot = options.boot || $tw.boot; - this.verbose = !!options.verbose; // Initialise the variables this.variables = $tw.utils.extend({},this.defaultVariables); if(options.variables) { @@ -45,14 +43,6 @@ function Server(options) { } } } - // Register server extensions - this.extensions = []; - $tw.modules.forEachModuleOfType("server-extension",function(title,exports) { - var extension = new exports.Extension(self); - self.extensions.push(extension); - }); - // Initialise server extensions - this.invokeExtensionHook("server-start-initialisation"); // Setup the default required plugins this.requiredPlugins = this.get("required-plugins").split(','); // Initialise CSRF @@ -105,16 +95,8 @@ function Server(options) { 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") || ""; - // Complete initialisation of server extensions - this.invokeExtensionHook("server-completed-initialisation"); } -Server.prototype.invokeExtensionHook = function(hookName) { - $tw.utils.each(this.extensions,function(extension) { - extension.hook(hookName); - }); -}; - /* 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 @@ -180,6 +162,12 @@ function sendResponse(request,response,statusCode,headers,data,encoding) { 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 @@ -255,8 +243,8 @@ function streamMultipartData(request,options) { } else { const boundaryIndex = buffer.indexOf(boundaryBuffer); if(boundaryIndex >= 0) { - // Return the part up to the boundary - options.cbPartChunk(Uint8Array.prototype.slice.call(buffer,0,boundaryIndex)); + // 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); @@ -368,15 +356,10 @@ Server.prototype.requestHandler = function(request,response,options) { 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); // Get the principals authorized to access this resource state.authorizationType = options.authorizationType || this.methodMappings[request.method] || "readers"; - // Check for the CSRF header if this is a write - if(!this.csrfDisable && state.authorizationType === "writers" && request.headers["x-requested-with"] !== "TiddlyWiki") { - response.writeHead(403,"'X-Requested-With' header required to login to '" + this.servername + "'"); - response.end(); - return; - } // Check whether anonymous access is granted state.allowAnon = this.isAuthorized(state.authorizationType,null); // Authenticate with the first active authenticator @@ -406,6 +389,12 @@ Server.prototype.requestHandler = function(request,response,options) { 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.writeHead(403,"'X-Requested-With' header required to login to '" + this.servername + "'"); + response.end(); + 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 @@ -466,10 +455,12 @@ Server.prototype.listen = function(port,host,prefix) { } // Create the server var server = this.transport.createServer(this.listenOptions || {},function(request,response,options) { - var start = new Date().getTime() - response.on("finish",function() { - // console.log("Request",request.method,request.url,(new Date().getTime()) - start); - }); + if(self.get("debug-level") !== "none") { + var start = $tw.utils.timer(); + response.on("finish",function() { + console.log("Response tim:",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) diff --git a/plugins/tiddlywiki/multiwikiserver/admin-ui/MultiWikiServer Administration.tid b/plugins/tiddlywiki/multiwikiserver/admin-ui/MultiWikiServer Administration.tid index 74551c44a..4b076166f 100644 --- a/plugins/tiddlywiki/multiwikiserver/admin-ui/MultiWikiServer Administration.tid +++ b/plugins/tiddlywiki/multiwikiserver/admin-ui/MultiWikiServer Administration.tid @@ -38,19 +38,19 @@ title: MultiWikiServer Administration - <$edit-text tiddler="$:/state/NewBagName" tag="input" placeholder="(bag name)" class="mws-form-field-input"/> + <$edit-text tiddler="$:/temp/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"/> + <$edit-text tiddler="$:/temp/NewBagDescription" tag="input" placeholder="(description)" class="mws-form-field-input"/>
- <%if [[$:/state/NewBagError]get[text]else[]!match[]] %> + <%if [[$:/temp/NewBagError]get[text]else[]!match[]] %>
- <$text text={{$:/state/NewBagError}}/> + <$text text={{$:/temp/NewBagError}}/>
<%endif%>
@@ -58,9 +58,9 @@ title: MultiWikiServer Administration <$button class="mws-form-button"> <$transclude $variable="createBag" - name={{$:/state/NewBagName}} - description={{$:/state/NewBagDescription}} - errorTiddler="$:/state/NewBagError" + name={{$:/temp/NewBagName}} + description={{$:/temp/NewBagDescription}} + errorTiddler="$:/temp/NewBagError" /> Create Bag @@ -103,25 +103,25 @@ title: MultiWikiServer Administration - <$edit-text tiddler="$:/state/NewRecipeName" tag="input" placeholder="(recipe name)" class="mws-form-field-input"/> + <$edit-text tiddler="$:/temp/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="$:/temp/NewRecipeBagNames" tag="input" placeholder="(space separated list of bags)"/>
- <$edit-text tiddler="$:/state/NewRecipeDescription" tag="input" placeholder="(description)" class="mws-form-field-input"/> + <$edit-text tiddler="$:/temp/NewRecipeDescription" tag="input" placeholder="(description)" class="mws-form-field-input"/>
- <%if [[$:/state/NewRecipeError]get[text]else[]!match[]] %> + <%if [[$:/temp/NewRecipeError]get[text]else[]!match[]] %>
- <$text text={{$:/state/NewRecipeError}}/> + <$text text={{$:/temp/NewRecipeError}}/>
<%endif%>
@@ -129,10 +129,10 @@ title: MultiWikiServer Administration <$button class="mws-form-button"> <$transclude $variable="createRecipe" - name={{$:/state/NewRecipeName}} - bag_names={{$:/state/NewRecipeBagNames}} - description={{$:/state/NewRecipeDescription}} - errorTiddler="$:/state/NewRecipeError" + name={{$:/temp/NewRecipeName}} + bag_names={{$:/temp/NewRecipeBagNames}} + description={{$:/temp/NewRecipeDescription}} + errorTiddler="$:/temp/NewRecipeError" /> Create Recipe diff --git a/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerAttachmentSizeLimit.tid b/plugins/tiddlywiki/multiwikiserver/config/MultiWikiServerAttachmentSizeLimit.tid new file mode 100644 index 000000000..e3396c6b8 --- /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/modules/attachment-store.js b/plugins/tiddlywiki/multiwikiserver/modules/attachment-store.js new file mode 100644 index 000000000..e9813fa06 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/attachment-store.js @@ -0,0 +1,140 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/attachment-store.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/ + part000 + part001 + ... + ... + 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(attachmentname) { + const re = new RegExp('^[a-f0-9]{64}$'); + return re.test(attachmentname); +}; + +/* +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 +*/ +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({ + 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) { + 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({ + 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(attachmentname) { + const path = require("path"), + fs = require("fs"); + // Check the attachment name + if(this.isValidAttachmentName(attachmentname)) { + // Construct the path to the attachment directory + const attachmentPath = path.resolve(this.storePath,"files",attachmentname); + // 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; +}; + +exports.AttachmentStore = AttachmentStore; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/init.js b/plugins/tiddlywiki/multiwikiserver/modules/init.js index 45cce6ab8..c3a7f2911 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/init.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/init.js @@ -21,14 +21,18 @@ exports.synchronous = true; exports.startup = function() { var path = require("path"); // Create and initialise the tiddler store and upload manager - var SqlTiddlerStore = require("$:/plugins/tiddlywiki/multiwikiserver/sql-tiddler-store.js").SqlTiddlerStore, + var AttachmentStore = require("$:/plugins/tiddlywiki/multiwikiserver/attachment-store.js").AttachmentStore, + attachmentStore = new AttachmentStore({ + storePath: path.resolve($tw.boot.wikiPath,"store/") + }), + SqlTiddlerStore = require("$:/plugins/tiddlywiki/multiwikiserver/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 + engine: $tw.wiki.getTiddlerText("$:/config/MultiWikiServer/Engine","better"), // better || wasm + attachmentStore: attachmentStore }), MultipartFormManager = require("$:/plugins/tiddlywiki/multiwikiserver/multipart-form-manager.js").MultipartFormManager, multipartFormManager = new MultipartFormManager({ - inboxPath: path.resolve($tw.boot.wikiPath,"store/inbox"), store: store }); $tw.mws = { diff --git a/plugins/tiddlywiki/multiwikiserver/modules/multipart-form-manager.js b/plugins/tiddlywiki/multiwikiserver/modules/multipart-form-manager.js index de81be4c2..616187147 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/multipart-form-manager.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/multipart-form-manager.js @@ -15,13 +15,10 @@ be moved out of the store/inbox folder. /* Create an instance of the upload manager. Options include: -inboxPath - path to the inbox folder store - sqlTiddlerStore to use for saving tiddlers */ function MultipartFormManager(options) { - const path = require("path"); options = options || {}; - this.inboxPath = options.inboxPath; this.store = options.store; } diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-delete-recipe-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/route-delete-recipe-tiddler.js index 0a2e5affb..1584243eb 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-delete-recipe-tiddler.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-delete-recipe-tiddler.js @@ -3,7 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/route-delete-recipe-tiddler.js type: application/javascript module-type: route -DELETE /wikis/:recipe_name/recipes/:bag_name/tiddler/:title +DELETE /wiki/:recipe_name/recipes/:bag_name/tiddler/:title NOTE: Urls currently include the recipe name twice. This is temporary to minimise the changes to the TiddlyWeb plugin diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-get-bag-tiddler-blob.js b/plugins/tiddlywiki/multiwikiserver/modules/route-get-bag-tiddler-blob.js new file mode 100644 index 000000000..bae09a8ad --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-get-bag-tiddler-blob.js @@ -0,0 +1,38 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/route-get-bag-tiddler-blob.js +type: application/javascript +module-type: route + +GET /wiki/:bag_name/bags/:bag_name/tiddler/:title/blob + +\*/ +(function() { + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.method = "GET"; + +exports.path = /^\/wiki\/([^\/]+)\/bags\/([^\/]+)\/tiddlers\/([^\/]+)\/blob$/; + +exports.handler = function(request,response,state) { + // Get the parameters + const bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]), + bag_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]), + title = $tw.utils.decodeURIComponentSafe(state.params[2]); + if(bag_name === bag_name_2) { + const result = $tw.mws.store.getBagTiddlerStream(title,bag_name); + if(result) { + response.writeHead(200, "OK",{ + "Content-Type": result.type + }); + result.stream.pipe(response); + return; + } + } + response.writeHead(404); + response.end(); +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-get-bag-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/route-get-bag-tiddler.js index 47c12cc80..8b883f5f6 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-get-bag-tiddler.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-get-bag-tiddler.js @@ -3,7 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/route-get-bag-tiddler.js type: application/javascript module-type: route -GET /wikis/:bag_name/bags/:bag_name/tiddler/:title +GET /wiki/:bag_name/bags/:bag_name/tiddler/:title NOTE: Urls currently include the bag name twice. This is temporary to minimise the changes to the TiddlyWeb plugin @@ -40,21 +40,22 @@ exports.handler = function(request,response,state) { } }); tiddlerFields.type = tiddlerFields.type || "text/vnd.tiddlywiki"; - tiddlerFields = $tw.mws.store.processCanonicalUriTiddler(tiddlerFields,bag_name,null); state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(tiddlerFields),"utf8"); + return; } else { // This is not a JSON API request, we should return the raw tiddler content - var type = result.tiddler.type || "text/plain"; - response.writeHead(200, "OK",{ - "Content-Type": type - }); - response.write(result.tiddler.text || "",($tw.config.contentTypeInfo[type] ||{encoding: "utf8"}).encoding); - response.end();; + const result = $tw.mws.store.getBagTiddlerStream(title,bag_name); + if(result) { + response.writeHead(200, "OK",{ + "Content-Type": result.type + }); + result.stream.pipe(response); + return; + } } - } else { - response.writeHead(404); - response.end(); } + response.writeHead(404); + response.end(); }; }()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-get-bag.js b/plugins/tiddlywiki/multiwikiserver/modules/route-get-bag.js index b446df724..0a31fcf5d 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-get-bag.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-get-bag.js @@ -3,7 +3,8 @@ title: $:/plugins/tiddlywiki/multiwikiserver/route-get-bag.js type: application/javascript module-type: route -GET /wikis/:bag_name/bags/:bag_name +GET /wiki/:bag_name/bags/:bag_name/ +GET /wiki/:bag_name/bags/:bag_name NOTE: Urls currently include the bag name twice. This is temporary to minimise the changes to the TiddlyWeb plugin @@ -16,9 +17,14 @@ NOTE: Urls currently include the bag name twice. This is temporary to minimise t exports.method = "GET"; -exports.path = /^\/wiki\/([^\/]+)\/bags\/(.+)$/; +exports.path = /^\/wiki\/([^\/]+)\/bags\/([^\/]+)(\/?)$/; 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[2] !== "/") { + state.redirect(301,state.urlInfo.path + "/"); + return; + } // Get the parameters var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]), bag_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]), @@ -40,7 +46,7 @@ exports.handler = function(request,response,state) { `); // Render the html - var html = $tw.mws.store.adminWiki.renderTiddler("text/html","$:/plugins/tiddlywiki/multiwikiserver/templates/get-bags",{ + var html = $tw.mws.store.adminWiki.renderTiddler("text/html","$:/plugins/tiddlywiki/multiwikiserver/templates/get-bag",{ variables: { "bag-name": bag_name, "bag-titles": JSON.stringify(titles) @@ -51,7 +57,7 @@ exports.handler = function(request,response,state) { `); - response.end();; + response.end(); } } else { response.writeHead(404); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe-tiddler.js index 236ae7d74..cdc792601 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe-tiddler.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe-tiddler.js @@ -3,7 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/route-get-recipe-tiddler.js type: application/javascript module-type: route -GET /wikis/:recipe_name/recipes/:recipe_name/tiddler/:title +GET /wiki/:recipe_name/recipes/:recipe_name/tiddler/:title NOTE: Urls currently include the recipe name twice. This is temporary to minimise the changes to the TiddlyWeb plugin @@ -40,7 +40,6 @@ exports.handler = function(request,response,state) { } }); tiddlerFields.type = tiddlerFields.type || "text/vnd.tiddlywiki"; - tiddlerFields = $tw.mws.store.processCanonicalUriTiddler(tiddlerFields,null,recipe_name); state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(tiddlerFields),"utf8"); } else { // This is not a JSON API request, we should return the raw tiddler content diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe-tiddlers-json.js b/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe-tiddlers-json.js index b1e7cc221..417339b87 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe-tiddlers-json.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe-tiddlers-json.js @@ -3,7 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/route-get-recipe-tiddlers-json.js type: application/javascript module-type: route -PUT /wikis/:recipe_name/recipes/:recipe_name/tiddlers.json?filter=:filter +PUT /wiki/:recipe_name/recipes/:recipe_name/tiddlers.json?filter=:filter NOTE: Urls currently include the recipe name twice. This is temporary to minimise the changes to the TiddlyWeb plugin diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe.js b/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe.js index 64d095105..6dc2ed4e2 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-get-recipe.js @@ -3,7 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/route-get-recipe.js type: application/javascript module-type: route -GET /wikis/:recipe_name +GET /wiki/:recipe_name \*/ (function() { @@ -18,15 +18,15 @@ exports.path = /^\/wiki\/([^\/]+)$/; exports.handler = function(request,response,state) { // Get the recipe name from the parameters - var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]); + 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) { + if(recipe_name && recipeTiddlers) { // Start the response response.writeHead(200, "OK",{ "Content-Type": "text/html" }); // Get the tiddlers in the recipe - var recipeTiddlers = $tw.mws.store.getRecipeTiddlers(recipe_name); // Render the template var template = $tw.mws.store.adminWiki.renderTiddler("text/plain","$:/core/templates/tiddlywiki5.html",{ variables: { @@ -53,7 +53,6 @@ exports.handler = function(request,response,state) { var result = $tw.mws.store.getRecipeTiddler(recipeTiddlerInfo.title,recipe_name); if(result) { var tiddlerFields = result.tiddler; - tiddlerFields = $tw.mws.store.processCanonicalUriTiddler(tiddlerFields,null,recipe_name); response.write(JSON.stringify(tiddlerFields).replace(/ 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(); + } + } + console.log(`Creating tiddler with ${JSON.stringify(tiddlerFields)} and ${partFile.filename}`) + $tw.mws.store.saveBagTiddlerWithAttachment(tiddlerFields,bag_name,{ + filepath: partFile.inboxFilename, + type: type, + hash: partFile.hash + }); + $tw.utils.deleteDirectory(inboxPath); + 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([tiddlerFields.title]) + } + }); + response.write(html); + response.write(` + + + `); + response.end(); + } + } + }); +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-post-recipe-tiddlers.js b/plugins/tiddlywiki/multiwikiserver/modules/route-post-recipe-tiddlers.js deleted file mode 100644 index 5327d93eb..000000000 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-post-recipe-tiddlers.js +++ /dev/null @@ -1,71 +0,0 @@ -/*\ -title: $:/plugins/tiddlywiki/multiwikiserver/route-post-recipe-tiddlers.js -type: application/javascript -module-type: route - -POST /wikis/:recipe_name/recipes/:recipe_name/tiddlers - -NOTE: Urls currently include the recipe name twice. This is temporary to minimise the changes to the TiddlyWeb plugin - -\*/ -(function() { - -/*jslint node: true, browser: true */ -/*global $tw: false */ -"use strict"; - -exports.method = "POST"; - -exports.path = /^\/wiki\/([^\/]+)\/recipes\/([^\/]+)\/tiddlers$/; - -exports.bodyFormat = "stream"; - -exports.handler = function(request,response,state) { - const fs = require("fs"); - // Get the parameters - var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]), - recipe_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]); -console.log(`Got to here ${recipe_name} and ${recipe_name_2}`) - // Require the recipe names to match - if(recipe_name !== recipe_name_2) { - return state.sendResponse(400,{"Content-Type": "text/plain"},"Bad Request: recipe names do not match"); - } - // Process the incoming data - let fileStream = null; - let fieldValue = ""; - state.streamMultipartData({ - cbPartStart: function(headers,name,filename) { - console.log(`Received file ${name} and ${filename} with ${JSON.stringify(headers)}`) - if(filename) { - fileStream = fs.createWriteStream(filename); - } else { - fieldValue = ""; - } - }, - cbPartChunk: function(chunk) { - if(fileStream) { - fileStream.write(chunk); - } else { - fieldValue = fieldValue + chunk; - } - }, - cbPartEnd: function() { - if(fileStream) { - fileStream.end(); - fileStream = null; - } else { - console.log("Data was " + fieldValue); - fieldValue = ""; - } - }, - cbFinished: function(err) { - if(err) { - state.sendResponse(400,{"Content-Type": "text/plain"},"Bad Request: " + err); - } else { - state.sendResponse(200, {"Content-Type": "text/plain"},"Multipart data processed"); - } - } - }); -}; - -}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-put-bag.js b/plugins/tiddlywiki/multiwikiserver/modules/route-put-bag.js index ad24b4bf2..bf7fb912e 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-put-bag.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-put-bag.js @@ -3,7 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/route-put-bag.js type: application/javascript module-type: route -PUT /wikis/:bag_name/bags/:bag_name +PUT /wiki/:bag_name/bags/:bag_name NOTE: Urls currently include the bag name twice. This is temporary to minimise the changes to the TiddlyWeb plugin diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe-tiddler.js index 6f0459111..fa4e986f9 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe-tiddler.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe-tiddler.js @@ -3,7 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/route-put-recipe-tiddler.js type: application/javascript module-type: route -PUT /wikis/:recipe_name/recipes/:recipe_name/tiddlers/:title +PUT /wiki/:recipe_name/recipes/:recipe_name/tiddlers/:title NOTE: Urls currently include the recipe name twice. This is temporary to minimise the changes to the TiddlyWeb plugin diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe.js b/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe.js index 4785f2a32..1fbed2c8d 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-put-recipe.js @@ -3,7 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/route-put-recipe.js type: application/javascript module-type: route -PUT /wikis/:recipe_name/recipes/:recipe_name +PUT /wiki/:recipe_name/recipes/:recipe_name NOTE: Urls currently include the recipe name twice. This is temporary to minimise the changes to the TiddlyWeb plugin diff --git a/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-database.js b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-database.js index d528021a5..a102b8ffa 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-database.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-database.js @@ -133,6 +133,7 @@ SqlTiddlerDatabase.prototype.createTables = function() { tiddler_id INTEGER PRIMARY KEY AUTOINCREMENT, bag_id INTEGER NOT NULL, title TEXT 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) ) @@ -253,16 +254,19 @@ SqlTiddlerDatabase.prototype.createRecipe = function(recipename,bagnames,descrip /* Returns {tiddler_id:} */ -SqlTiddlerDatabase.prototype.saveBagTiddler = function(tiddlerFields,bagname) { +SqlTiddlerDatabase.prototype.saveBagTiddler = function(tiddlerFields,bagname,attachment_blob) { + attachment_blob = attachment_blob || null; // Update the tiddlers table var info = this.runStatement(` - INSERT OR REPLACE INTO tiddlers (bag_id, title) + INSERT OR REPLACE INTO tiddlers (bag_id, title, attachment_blob) VALUES ( (SELECT bag_id FROM bags WHERE bag_name = $bag_name), - $title + $title, + $attachment_blob ) `,{ $title: tiddlerFields.title, + $attachment_blob: attachment_blob, $bag_name: bagname }); // Update the fields table @@ -295,7 +299,7 @@ SqlTiddlerDatabase.prototype.saveBagTiddler = function(tiddlerFields,bagname) { /* Returns {tiddler_id:,bag_name:} or null if the recipe is empty */ -SqlTiddlerDatabase.prototype.saveRecipeTiddler = function(tiddlerFields,recipename) { +SqlTiddlerDatabase.prototype.saveRecipeTiddler = function(tiddlerFields,recipename,attachment_blob) { // Find the topmost bag in the recipe var row = this.runStatementGet(` SELECT b.bag_name @@ -319,7 +323,7 @@ SqlTiddlerDatabase.prototype.saveRecipeTiddler = function(tiddlerFields,recipena return null; } // Save the tiddler to the topmost bag - var info = this.saveBagTiddler(tiddlerFields,row.bag_name); + var info = this.saveBagTiddler(tiddlerFields,row.bag_name,attachment_blob); return { tiddler_id: info.tiddler_id, bag_name: row.bag_name @@ -354,27 +358,34 @@ SqlTiddlerDatabase.prototype.deleteTiddler = function(title,bagname) { }; /* -returns {tiddler_id:,tiddler:} +returns {tiddler_id:,tiddler:,attachment_blob:} */ SqlTiddlerDatabase.prototype.getBagTiddler = function(title,bagname) { - const rows = this.runStatementGetAll(` - SELECT field_name, field_value, tiddler_id - FROM fields - WHERE tiddler_id = ( - SELECT t.tiddler_id - 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 - ) + const rowTiddler = this.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 `,{ $title: title, $bag_name: bagname }); + if(!rowTiddler) { + return null; + } + const rows = this.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; @@ -384,11 +395,11 @@ SqlTiddlerDatabase.prototype.getBagTiddler = function(title,bagname) { }; /* -Returns {bag_name:, tiddler: {fields}, tiddler_id:} +Returns {bag_name:, tiddler: {fields}, tiddler_id:, attachment_blob:} */ SqlTiddlerDatabase.prototype.getRecipeTiddler = function(title,recipename) { const rowTiddlerId = this.runStatementGet(` - SELECT t.tiddler_id, b.bag_name + 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 @@ -415,6 +426,7 @@ SqlTiddlerDatabase.prototype.getRecipeTiddler = function(title,recipename) { 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; @@ -442,9 +454,17 @@ SqlTiddlerDatabase.prototype.getBagTiddlers = function(bagname) { }; /* -Get the titles of the tiddlers in a recipe as {title:,bag_name:}. Returns an empty array for recipes that do not exist +Get the titles of the tiddlers in a recipe as {title:,bag_name:}. Returns null for recipes that do not exist */ SqlTiddlerDatabase.prototype.getRecipeTiddlers = function(recipename) { + const rowsCheckRecipe = this.runStatementGetAll(` + SELECT * FROM recipes WHERE recipes.recipe_name = $recipe_name + `,{ + $recipe_name: recipename + }); + if(rowsCheckRecipe.length === 0) { + return null; + } const rows = this.runStatementGetAll(` SELECT title, bag_name FROM ( diff --git a/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js index b331fb58b..b0ce7ef08 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js @@ -9,7 +9,7 @@ This class is largely a wrapper for the sql-tiddler-database.js class, adding th * Validating requests (eg bag and recipe name constraints) * Synchronising bag and recipe names to the admin wiki -* Handling _canonical_uri tiddlers +* Handling large tiddlers as attachments \*/ @@ -20,10 +20,12 @@ 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 into which entity state tiddlers should be saved +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.entityStateTiddlerPrefix = "$:/state/MultiWikiServer/"; // Create the database @@ -114,31 +116,43 @@ Given tiddler fields, tiddler_id and a bagname, return the tiddler fields after - 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) { - return Object.assign({},tiddlerFields,{ +SqlTiddlerStore.prototype.processOutgoingTiddler = function(tiddlerFields,tiddler_id,bag_name,attachment_blob) { + const fields = Object.assign({},tiddlerFields,{ revision: "" + tiddler_id, bag: bag_name }); + if(attachment_blob !== null) { + delete fields.text; + fields._canonical_uri = `/wiki/${encodeURIComponent(bag_name)}/bags/${encodeURIComponent(bag_name)}/tiddlers/${encodeURIComponent(tiddlerFields.title)}/blob`; + } + return fields; }; /* -Given tiddler fields and a bagname or a recipename, if the text field is over a threshold, modify -the tiddler to use _canonical_uri, otherwise return the tiddler unmodified */ -SqlTiddlerStore.prototype.processCanonicalUriTiddler = function(tiddlerFields,bag_name,recipe_name) { - if((tiddlerFields.text || "").length > 10 * 1024 * 1024) { - return Object.assign({},tiddlerFields,{ - text: undefined, - _canonical_uri: recipe_name - ? `/wiki/${recipe_name}/recipes/${recipe_name}/tiddlers/${tiddlerFields.title}` - : `/wiki/${bag_name}/bags/${bag_name}/tiddlers/${tiddlerFields.title}` +SqlTiddlerStore.prototype.processIncomingTiddler = function(tiddlerFields) { + let attachmentSizeLimit = $tw.utils.parseNumber(this.adminWiki.getTiddlerText("$:/config/MultiWikiServer/AttachmentSizeLimit")); + if(attachmentSizeLimit < 100 * 1024) { + attachmentSizeLimit = 100 * 1024; + } + if(tiddlerFields.text && tiddlerFields.text.length > attachmentSizeLimit) { + const attachment_blob = this.attachmentStore.saveAttachment({ + text: tiddlerFields.text, + type: tiddlerFields.type, + reference: tiddlerFields.title }); + return { + tiddlerFields: Object.assign({},tiddlerFields,{text: undefined}), + attachment_blob: attachment_blob + }; } else { - return tiddlerFields; + return { + tiddlerFields: tiddlerFields, + attachment_blob: null + }; } }; - SqlTiddlerStore.prototype.saveTiddlersFromPath = function(tiddler_files_path,bag_name) { var self = this; this.sqlTiddlerDatabase.transaction(function() { @@ -150,7 +164,7 @@ SqlTiddlerStore.prototype.saveTiddlersFromPath = function(tiddler_files_path,bag // Save the tiddlers for(const tiddlersFromFile of tiddlersFromPath) { for(const tiddler of tiddlersFromFile.tiddlers) { - self.saveBagTiddler(tiddler,bag_name); + self.saveBagTiddler(tiddler,bag_name,null); } } }); @@ -220,15 +234,37 @@ SqlTiddlerStore.prototype.createRecipe = function(recipename,bagnames,descriptio /* Returns {tiddler_id:} */ -SqlTiddlerStore.prototype.saveBagTiddler = function(tiddlerFields,bagname) { - return this.sqlTiddlerDatabase.saveBagTiddler(tiddlerFields,bagname); +SqlTiddlerStore.prototype.saveBagTiddler = function(incomingTiddlerFields,bagname) { + const {tiddlerFields, attachment_blob} = this.processIncomingTiddler(incomingTiddlerFields); + return this.sqlTiddlerDatabase.saveBagTiddler(tiddlerFields,bagname,attachment_blob); +}; + +/* +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,bagname,options) { + console.log(`saveBagTiddlerWithAttachment ${JSON.stringify(incomingTiddlerFields)}, ${bagname}, ${JSON.stringify(options)}`); + const attachment_blob = this.attachmentStore.adoptAttachment(options.filepath,options.type,options.hash); + if(attachment_blob) { + return this.sqlTiddlerDatabase.saveBagTiddler(incomingTiddlerFields,bagname,attachment_blob); + } else { + return null; + } }; /* Returns {tiddler_id:,bag_name:} */ -SqlTiddlerStore.prototype.saveRecipeTiddler = function(tiddlerFields,recipename) { - return this.sqlTiddlerDatabase.saveRecipeTiddler(tiddlerFields,recipename); +SqlTiddlerStore.prototype.saveRecipeTiddler = function(incomingTiddlerFields,recipename) { + const {tiddlerFields, attachment_blob} = this.processIncomingTiddler(incomingTiddlerFields); + return this.sqlTiddlerDatabase.saveRecipeTiddler(tiddlerFields,recipename,attachment_blob); }; SqlTiddlerStore.prototype.deleteTiddler = function(title,bagname) { @@ -245,13 +281,43 @@ SqlTiddlerStore.prototype.getBagTiddler = function(title,bagname) { {}, tiddlerInfo, { - tiddler: this.processOutgoingTiddler(tiddlerInfo.tiddler,tiddlerInfo.tiddler_id,bagname) + tiddler: this.processOutgoingTiddler(tiddlerInfo.tiddler,tiddlerInfo.tiddler_id,bagname,tiddlerInfo.attachment_blob) }); } else { return null; } }; +/* +Get an attachment ready to stream. Returns null if there is an error or: +stream: stream of file +type: type of file +*/ +SqlTiddlerStore.prototype.getBagTiddlerStream = function(title,bagname) { + const tiddlerInfo = this.sqlTiddlerDatabase.getBagTiddler(title,bagname); + if(tiddlerInfo) { + if(tiddlerInfo.attachment_blob) { + return this.attachmentStore.getAttachmentStream(tiddlerInfo.attachment_blob); + } 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 { + stream: stream, + type: tiddlerInfo.tiddler.type || "text/plain" + } + } + } else { + return null; + } +}; + /* Returns {bag_name:, tiddler: {fields}, tiddler_id:} */ @@ -262,7 +328,7 @@ SqlTiddlerStore.prototype.getRecipeTiddler = function(title,recipename) { {}, tiddlerInfo, { - tiddler: this.processOutgoingTiddler(tiddlerInfo.tiddler,tiddlerInfo.tiddler_id,tiddlerInfo.bag_name) + tiddler: this.processOutgoingTiddler(tiddlerInfo.tiddler,tiddlerInfo.tiddler_id,tiddlerInfo.bag_name,tiddlerInfo.attachment_blob) }); } else { return null; @@ -277,7 +343,7 @@ SqlTiddlerStore.prototype.getBagTiddlers = function(bagname) { }; /* -Get the titles of the tiddlers in a recipe as {title:,bag_name:}. Returns an empty array for recipes that do not exist +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(recipename) { return this.sqlTiddlerDatabase.getRecipeTiddlers(recipename); diff --git a/plugins/tiddlywiki/multiwikiserver/templates/get-bag.tid b/plugins/tiddlywiki/multiwikiserver/templates/get-bag.tid new file mode 100644 index 000000000..2c8e43e2f --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/templates/get-bag.tid @@ -0,0 +1,51 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-bag + +! <$image + source=`/wiki/${ [encodeuricomponent[]] }$/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={{{ []}}}/> + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + diff --git a/plugins/tiddlywiki/multiwikiserver/templates/get-bags.tid b/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers.tid similarity index 70% rename from plugins/tiddlywiki/multiwikiserver/templates/get-bags.tid rename to plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers.tid index 7ec3e74b6..01d0fa898 100644 --- a/plugins/tiddlywiki/multiwikiserver/templates/get-bags.tid +++ b/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers.tid @@ -1,4 +1,4 @@ -title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-bags +title: $:/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers ! <$image source=`/wiki/${ [encodeuricomponent[]] }$/bags/${ [encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico` @@ -12,8 +12,16 @@ title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-bags /> Bag <$text text={{{ []}}}/> +

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

+ +

+The following tiddlers were successfully imported: +

+