mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 18:36:30 +01:00 
			
		
		
		
	added support for trilium-sender
This commit is contained in:
		
							
								
								
									
										7
									
								
								db/migrations/0075__add_api_token.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								db/migrations/0075__add_api_token.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| CREATE TABLE IF NOT EXISTS "api_tokens" | ||||
| ( | ||||
|   apiTokenId TEXT PRIMARY KEY NOT NULL, | ||||
|   token TEXT NOT NULL, | ||||
|   dateCreated TEXT NOT NULL, | ||||
|   isDeleted INT NOT NULL DEFAULT 0 | ||||
| ); | ||||
| @@ -119,3 +119,11 @@ CREATE INDEX IDX_note_images_noteId ON note_images (noteId); | ||||
| CREATE INDEX IDX_note_images_imageId ON note_images (imageId); | ||||
| CREATE INDEX IDX_note_images_noteId_imageId ON note_images (noteId, imageId); | ||||
| CREATE INDEX IDX_attributes_noteId ON attributes (noteId); | ||||
|  | ||||
| CREATE TABLE IF NOT EXISTS "api_tokens" | ||||
| ( | ||||
|   apiTokenId TEXT PRIMARY KEY NOT NULL, | ||||
|   token TEXT NOT NULL, | ||||
|   dateCreated TEXT NOT NULL, | ||||
|   isDeleted INT NOT NULL DEFAULT 0 | ||||
| ); | ||||
| @@ -4,16 +4,8 @@ const express = require('express'); | ||||
| const router = express.Router(); | ||||
| const sql = require('../../services/sql'); | ||||
| const auth = require('../../services/auth'); | ||||
| const utils = require('../../services/utils'); | ||||
| const sync_table = require('../../services/sync_table'); | ||||
| const image = require('../../services/image'); | ||||
| const multer = require('multer')(); | ||||
| const imagemin = require('imagemin'); | ||||
| const imageminMozJpeg = require('imagemin-mozjpeg'); | ||||
| const imageminPngQuant = require('imagemin-pngquant'); | ||||
| const imageminGifLossy = require('imagemin-giflossy'); | ||||
| const jimp = require('jimp'); | ||||
| const imageType = require('image-type'); | ||||
| const sanitizeFilename = require('sanitize-filename'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
| const RESOURCE_DIR = require('../../services/resource_dir').RESOURCE_DIR; | ||||
| const fs = require('fs'); | ||||
| @@ -49,45 +41,7 @@ router.post('', auth.checkApiAuthOrElectron, multer.single('upload'), wrap(async | ||||
|         return res.status(400).send("Unknown image type: " + file.mimetype); | ||||
|     } | ||||
|  | ||||
|     const now = utils.nowDate(); | ||||
|  | ||||
|     const resizedImage = await resize(file.buffer); | ||||
|     const optimizedImage = await optimize(resizedImage); | ||||
|  | ||||
|     const imageFormat = imageType(optimizedImage); | ||||
|  | ||||
|     const fileNameWithouExtension = file.originalname.replace(/\.[^/.]+$/, ""); | ||||
|     const fileName = sanitizeFilename(fileNameWithouExtension + "." + imageFormat.ext); | ||||
|  | ||||
|     const imageId = utils.newImageId(); | ||||
|  | ||||
|     await sql.doInTransaction(async () => { | ||||
|         await sql.insert("images", { | ||||
|             imageId: imageId, | ||||
|             format: imageFormat.ext, | ||||
|             name: fileName, | ||||
|             checksum: utils.hash(optimizedImage), | ||||
|             data: optimizedImage, | ||||
|             isDeleted: 0, | ||||
|             dateModified: now, | ||||
|             dateCreated: now | ||||
|         }); | ||||
|  | ||||
|         await sync_table.addImageSync(imageId, sourceId); | ||||
|  | ||||
|         const noteImageId = utils.newNoteImageId(); | ||||
|  | ||||
|         await sql.insert("note_images", { | ||||
|             noteImageId: noteImageId, | ||||
|             noteId: noteId, | ||||
|             imageId: imageId, | ||||
|             isDeleted: 0, | ||||
|             dateModified: now, | ||||
|             dateCreated: now | ||||
|         }); | ||||
|  | ||||
|         await sync_table.addNoteImageSync(noteImageId, sourceId); | ||||
|     }); | ||||
|     const {fileName, imageId} = await image.saveImage(file, sourceId, noteId); | ||||
|  | ||||
|     res.send({ | ||||
|         uploaded: true, | ||||
| @@ -95,54 +49,4 @@ router.post('', auth.checkApiAuthOrElectron, multer.single('upload'), wrap(async | ||||
|     }); | ||||
| })); | ||||
|  | ||||
| const MAX_SIZE = 1000; | ||||
| const MAX_BYTE_SIZE = 200000; // images should have under 100 KBs | ||||
|  | ||||
| async function resize(buffer) { | ||||
|     const image = await jimp.read(buffer); | ||||
|  | ||||
|     if (image.bitmap.width > image.bitmap.height && image.bitmap.width > MAX_SIZE) { | ||||
|         image.resize(MAX_SIZE, jimp.AUTO); | ||||
|     } | ||||
|     else if (image.bitmap.height > MAX_SIZE) { | ||||
|         image.resize(jimp.AUTO, MAX_SIZE); | ||||
|     } | ||||
|     else if (buffer.byteLength <= MAX_BYTE_SIZE) { | ||||
|         return buffer; | ||||
|     } | ||||
|  | ||||
|     // we do resizing with max quality which will be trimmed during optimization step next | ||||
|     image.quality(100); | ||||
|  | ||||
|     // when converting PNG to JPG we lose alpha channel, this is replaced by white to match Trilium white background | ||||
|     image.background(0xFFFFFFFF); | ||||
|  | ||||
|     // getBuffer doesn't support promises so this workaround | ||||
|     return await new Promise((resolve, reject) => image.getBuffer(jimp.MIME_JPEG, (err, data) => { | ||||
|         if (err) { | ||||
|             reject(err); | ||||
|         } | ||||
|         else { | ||||
|             resolve(data); | ||||
|         } | ||||
|     })); | ||||
| } | ||||
|  | ||||
| async function optimize(buffer) { | ||||
|     return await imagemin.buffer(buffer, { | ||||
|         plugins: [ | ||||
|             imageminMozJpeg({ | ||||
|                 quality: 50 | ||||
|             }), | ||||
|             imageminPngQuant({ | ||||
|                 quality: "0-70" | ||||
|             }), | ||||
|             imageminGifLossy({ | ||||
|                 lossy: 80, | ||||
|                 optimize: '3' // needs to be string | ||||
|             }) | ||||
|         ] | ||||
|     }); | ||||
| } | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -66,7 +66,7 @@ async function importNotes(dir, parentNoteId) { | ||||
|         const noteText = fs.readFileSync(path, "utf8"); | ||||
|  | ||||
|         const noteId = utils.newNoteId(); | ||||
|         const noteTreeId = utils.newnoteRevisionId(); | ||||
|         const noteTreeId = utils.newNoteRevisionId(); | ||||
|  | ||||
|         const now = utils.nowDate(); | ||||
|  | ||||
|   | ||||
							
								
								
									
										91
									
								
								src/routes/api/sender.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/routes/api/sender.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| "use strict"; | ||||
|  | ||||
| const express = require('express'); | ||||
| const router = express.Router(); | ||||
| const image = require('../../services/image'); | ||||
| const utils = require('../../services/utils'); | ||||
| const date_notes = require('../../services/date_notes'); | ||||
| const sql = require('../../services/sql'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
| const notes = require('../../services/notes'); | ||||
| const multer = require('multer')(); | ||||
| const password_encryption = require('../../services/password_encryption'); | ||||
| const options = require('../../services/options'); | ||||
| const sync_table = require('../../services/sync_table'); | ||||
|  | ||||
| router.post('/login', wrap(async (req, res, next) => { | ||||
|     const username = req.body.username; | ||||
|     const password = req.body.password; | ||||
|  | ||||
|     const isUsernameValid = username === await options.getOption('username'); | ||||
|     const isPasswordValid = await password_encryption.verifyPassword(password); | ||||
|  | ||||
|     if (!isUsernameValid || !isPasswordValid) { | ||||
|         res.status(401).send("Incorrect username/password"); | ||||
|     } | ||||
|     else { | ||||
|         const token = utils.randomSecureToken(); | ||||
|  | ||||
|         await sql.doInTransaction(async () => { | ||||
|             const apiTokenId = utils.newApiTokenId(); | ||||
|  | ||||
|             await sql.insert("api_tokens", { | ||||
|                 apiTokenId: apiTokenId, | ||||
|                 token: token, | ||||
|                 dateCreated: utils.nowDate(), | ||||
|                 isDeleted: false | ||||
|             }); | ||||
|  | ||||
|             await sync_table.addApiTokenSync(apiTokenId); | ||||
|         }); | ||||
|  | ||||
|         res.send({ | ||||
|             token: token | ||||
|         }); | ||||
|     } | ||||
| })); | ||||
|  | ||||
| async function checkSenderToken(req, res, next) { | ||||
|     const token = req.headers.authorization; | ||||
|  | ||||
|     if (await sql.getValue("SELECT COUNT(*) FROM api_tokens WHERE isDeleted = 0 AND token = ?", [token]) === 0) { | ||||
|         res.status(401).send("Not authorized"); | ||||
|     } | ||||
|     else if (await sql.isDbUpToDate()) { | ||||
|         next(); | ||||
|     } | ||||
|     else { | ||||
|         res.status(409).send("Mismatched app versions"); // need better response than that | ||||
|     } | ||||
| } | ||||
|  | ||||
| router.post('/image', checkSenderToken, multer.single('upload'), wrap(async (req, res, next) => { | ||||
|     const file = req.file; | ||||
|  | ||||
|     if (!["image/png", "image/jpeg", "image/gif"].includes(file.mimetype)) { | ||||
|         return res.status(400).send("Unknown image type: " + file.mimetype); | ||||
|     } | ||||
|  | ||||
|     const parentNoteId = await date_notes.getDateNoteId(utils.nowDate()); | ||||
|  | ||||
|     const noteId = (await notes.createNewNote(parentNoteId, { | ||||
|         title: "Sender image", | ||||
|         content: "", | ||||
|         target: 'into', | ||||
|         isProtected: false, | ||||
|         type: 'text', | ||||
|         mime: 'text/html' | ||||
|     })).noteId; | ||||
|  | ||||
|     const {fileName, imageId} = await image.saveImage(file, null, noteId); | ||||
|  | ||||
|     const url = `/api/images/${imageId}/${fileName}`; | ||||
|  | ||||
|     const content = `<img src="${url}"/>`; | ||||
|  | ||||
|     await sql.execute("UPDATE notes SET content = ? WHERE noteId = ?", [content, noteId]); | ||||
|  | ||||
|     res.send({}); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -147,6 +147,12 @@ router.get('/attributes/:attributeId', auth.checkApiAuth, wrap(async (req, res, | ||||
|     res.send(await sql.getRow("SELECT * FROM attributes WHERE attributeId = ?", [attributeId])); | ||||
| })); | ||||
|  | ||||
| router.get('/api_tokens/:apiTokenId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const apiTokenId = req.params.apiTokenId; | ||||
|  | ||||
|     res.send(await sql.getRow("SELECT * FROM api_tokens WHERE apiTokenId = ?", [apiTokenId])); | ||||
| })); | ||||
|  | ||||
| router.put('/notes', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await syncUpdate.updateNote(req.body.entity, req.body.sourceId); | ||||
|  | ||||
| @@ -201,4 +207,10 @@ router.put('/attributes', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     res.send({}); | ||||
| })); | ||||
|  | ||||
| router.put('/api_tokens', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await syncUpdate.updateApiToken(req.body.entity, req.body.sourceId); | ||||
|  | ||||
|     res.send({}); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -28,6 +28,7 @@ const cleanupRoute = require('./api/cleanup'); | ||||
| const imageRoute = require('./api/image'); | ||||
| const attributesRoute = require('./api/attributes'); | ||||
| const scriptRoute = require('./api/script'); | ||||
| const senderRoute = require('./api/sender'); | ||||
|  | ||||
| function register(app) { | ||||
|     app.use('/', indexRoute); | ||||
| @@ -59,6 +60,7 @@ function register(app) { | ||||
|     app.use('/api/cleanup', cleanupRoute); | ||||
|     app.use('/api/images', imageRoute); | ||||
|     app.use('/api/script', scriptRoute); | ||||
|     app.use('/api/sender', senderRoute); | ||||
| } | ||||
|  | ||||
| module.exports = { | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
| const build = require('./build'); | ||||
| const packageJson = require('../../package'); | ||||
|  | ||||
| const APP_DB_VERSION = 74; | ||||
| const APP_DB_VERSION = 75; | ||||
|  | ||||
| module.exports = { | ||||
|     app_version: packageJson.version, | ||||
|   | ||||
| @@ -223,6 +223,8 @@ async function runAllChecks() { | ||||
|     await runSyncRowChecks("recent_notes", "noteTreeId", errorList); | ||||
|     await runSyncRowChecks("images", "imageId", errorList); | ||||
|     await runSyncRowChecks("note_images", "noteImageId", errorList); | ||||
|     await runSyncRowChecks("attributes", "attributeId", errorList); | ||||
|     await runSyncRowChecks("api_tokens", "apiTokenId", errorList); | ||||
|  | ||||
|     if (errorList.length === 0) { | ||||
|         // we run this only if basic checks passed since this assumes basic data consistency | ||||
|   | ||||
							
								
								
									
										108
									
								
								src/services/image.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/services/image.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| "use strict"; | ||||
|  | ||||
| const utils = require('./utils'); | ||||
| const sql = require('./sql'); | ||||
| const sync_table = require('./sync_table'); | ||||
| const imagemin = require('imagemin'); | ||||
| const imageminMozJpeg = require('imagemin-mozjpeg'); | ||||
| const imageminPngQuant = require('imagemin-pngquant'); | ||||
| const imageminGifLossy = require('imagemin-giflossy'); | ||||
| const jimp = require('jimp'); | ||||
| const imageType = require('image-type'); | ||||
| const sanitizeFilename = require('sanitize-filename'); | ||||
|  | ||||
| async function saveImage(file, sourceId, noteId) { | ||||
|     const resizedImage = await resize(file.buffer); | ||||
|     const optimizedImage = await optimize(resizedImage); | ||||
|  | ||||
|     const imageFormat = imageType(optimizedImage); | ||||
|  | ||||
|     const fileNameWithouExtension = file.originalname.replace(/\.[^/.]+$/, ""); | ||||
|     const fileName = sanitizeFilename(fileNameWithouExtension + "." + imageFormat.ext); | ||||
|  | ||||
|     const imageId = utils.newImageId(); | ||||
|     const now = utils.nowDate(); | ||||
|  | ||||
|     await sql.doInTransaction(async () => { | ||||
|         await sql.insert("images", { | ||||
|             imageId: imageId, | ||||
|             format: imageFormat.ext, | ||||
|             name: fileName, | ||||
|             checksum: utils.hash(optimizedImage), | ||||
|             data: optimizedImage, | ||||
|             isDeleted: 0, | ||||
|             dateModified: now, | ||||
|             dateCreated: now | ||||
|         }); | ||||
|  | ||||
|         await sync_table.addImageSync(imageId, sourceId); | ||||
|  | ||||
|         const noteImageId = utils.newNoteImageId(); | ||||
|  | ||||
|         await sql.insert("note_images", { | ||||
|             noteImageId: noteImageId, | ||||
|             noteId: noteId, | ||||
|             imageId: imageId, | ||||
|             isDeleted: 0, | ||||
|             dateModified: now, | ||||
|             dateCreated: now | ||||
|         }); | ||||
|  | ||||
|         await sync_table.addNoteImageSync(noteImageId, sourceId); | ||||
|     }); | ||||
|     return {fileName, imageId}; | ||||
| } | ||||
|  | ||||
| const MAX_SIZE = 1000; | ||||
| const MAX_BYTE_SIZE = 200000; // images should have under 100 KBs | ||||
|  | ||||
| async function resize(buffer) { | ||||
|     const image = await jimp.read(buffer); | ||||
|  | ||||
|     if (image.bitmap.width > image.bitmap.height && image.bitmap.width > MAX_SIZE) { | ||||
|         image.resize(MAX_SIZE, jimp.AUTO); | ||||
|     } | ||||
|     else if (image.bitmap.height > MAX_SIZE) { | ||||
|         image.resize(jimp.AUTO, MAX_SIZE); | ||||
|     } | ||||
|     else if (buffer.byteLength <= MAX_BYTE_SIZE) { | ||||
|         return buffer; | ||||
|     } | ||||
|  | ||||
|     // we do resizing with max quality which will be trimmed during optimization step next | ||||
|     image.quality(100); | ||||
|  | ||||
|     // when converting PNG to JPG we lose alpha channel, this is replaced by white to match Trilium white background | ||||
|     image.background(0xFFFFFFFF); | ||||
|  | ||||
|     // getBuffer doesn't support promises so this workaround | ||||
|     return await new Promise((resolve, reject) => image.getBuffer(jimp.MIME_JPEG, (err, data) => { | ||||
|         if (err) { | ||||
|             reject(err); | ||||
|         } | ||||
|         else { | ||||
|             resolve(data); | ||||
|         } | ||||
|     })); | ||||
| } | ||||
|  | ||||
| async function optimize(buffer) { | ||||
|     return await imagemin.buffer(buffer, { | ||||
|         plugins: [ | ||||
|             imageminMozJpeg({ | ||||
|                 quality: 50 | ||||
|             }), | ||||
|             imageminPngQuant({ | ||||
|                 quality: "0-70" | ||||
|             }), | ||||
|             imageminGifLossy({ | ||||
|                 lossy: 80, | ||||
|                 optimize: '3' // needs to be string | ||||
|             }) | ||||
|         ] | ||||
|     }); | ||||
| } | ||||
|  | ||||
| module.exports = { | ||||
|     saveImage | ||||
| }; | ||||
| @@ -154,10 +154,10 @@ async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) { | ||||
|         note.isProtected = false; | ||||
|     } | ||||
|  | ||||
|     const newnoteRevisionId = utils.newnoteRevisionId(); | ||||
|     const newNoteRevisionId = utils.newNoteRevisionId(); | ||||
|  | ||||
|     await sql.insert('note_revisions', { | ||||
|         noteRevisionId: newnoteRevisionId, | ||||
|         noteRevisionId: newNoteRevisionId, | ||||
|         noteId: noteId, | ||||
|         // title and text should be decrypted now | ||||
|         title: oldNote.title, | ||||
| @@ -167,7 +167,7 @@ async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) { | ||||
|         dateModifiedTo: nowStr | ||||
|     }); | ||||
|  | ||||
|     await sync_table.addNoteHistorySync(newnoteRevisionId, sourceId); | ||||
|     await sync_table.addNoteHistorySync(newNoteRevisionId, sourceId); | ||||
| } | ||||
|  | ||||
| async function saveNoteImages(noteId, noteText, sourceId) { | ||||
|   | ||||
| @@ -149,6 +149,9 @@ async function pullSync(syncContext) { | ||||
|         else if (sync.entityName === 'attributes') { | ||||
|             await syncUpdate.updateAttribute(resp, syncContext.sourceId); | ||||
|         } | ||||
|         else if (sync.entityName === 'api_tokens') { | ||||
|             await syncUpdate.updateApiToken(resp, syncContext.sourceId); | ||||
|         } | ||||
|         else { | ||||
|             throw new Error(`Unrecognized entity type ${sync.entityName} in sync #${sync.id}`); | ||||
|         } | ||||
| @@ -233,6 +236,9 @@ async function pushEntity(sync, syncContext) { | ||||
|     else if (sync.entityName === 'attributes') { | ||||
|         entity = await sql.getRow('SELECT * FROM attributes WHERE attributeId = ?', [sync.entityId]); | ||||
|     } | ||||
|     else if (sync.entityName === 'api_tokens') { | ||||
|         entity = await sql.getRow('SELECT * FROM api_tokens WHERE apiTokenId = ?', [sync.entityId]); | ||||
|     } | ||||
|     else { | ||||
|         throw new Error(`Unrecognized entity type ${sync.entityName} in sync #${sync.id}`); | ||||
|     } | ||||
|   | ||||
| @@ -40,6 +40,10 @@ async function addAttributeSync(attributeId, sourceId) { | ||||
|     await addEntitySync("attributes", attributeId, sourceId); | ||||
| } | ||||
|  | ||||
| async function addApiTokenSync(apiTokenId, sourceId) { | ||||
|     await addEntitySync("api_tokens", apiTokenId, sourceId); | ||||
| } | ||||
|  | ||||
| async function addEntitySync(entityName, entityId, sourceId) { | ||||
|     await sql.replace("sync", { | ||||
|         entityName: entityName, | ||||
| @@ -93,6 +97,7 @@ async function fillAllSyncRows() { | ||||
|     await fillSyncRows("images", "imageId"); | ||||
|     await fillSyncRows("note_images", "noteImageId"); | ||||
|     await fillSyncRows("attributes", "attributeId"); | ||||
|     await fillSyncRows("api_tokens", "apiTokenId"); | ||||
| } | ||||
|  | ||||
| module.exports = { | ||||
| @@ -105,6 +110,7 @@ module.exports = { | ||||
|     addImageSync, | ||||
|     addNoteImageSync, | ||||
|     addAttributeSync, | ||||
|     addApiTokenSync, | ||||
|     addEntitySync, | ||||
|     cleanupSyncRowsForMissingEntities, | ||||
|     fillAllSyncRows | ||||
|   | ||||
| @@ -137,6 +137,20 @@ async function updateAttribute(entity, sourceId) { | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function updateApiToken(entity, sourceId) { | ||||
|     const apiTokenId = await sql.getRow("SELECT * FROM api_tokens WHERE apiTokenId = ?", [entity.apiTokenId]); | ||||
|  | ||||
|     if (!apiTokenId) { | ||||
|         await sql.doInTransaction(async () => { | ||||
|             await sql.replace("api_tokens", entity); | ||||
|  | ||||
|             await sync_table.addApiTokenSync(entity.apiTokenId, sourceId); | ||||
|         }); | ||||
|  | ||||
|         log.info("Update/sync API token " + entity.apiTokenId); | ||||
|     } | ||||
| } | ||||
|  | ||||
| module.exports = { | ||||
|     updateNote, | ||||
|     updateNoteTree, | ||||
| @@ -146,5 +160,6 @@ module.exports = { | ||||
|     updateRecentNotes, | ||||
|     updateImage, | ||||
|     updateNoteImage, | ||||
|     updateAttribute | ||||
|     updateAttribute, | ||||
|     updateApiToken | ||||
| }; | ||||
| @@ -11,7 +11,7 @@ function newNoteTreeId() { | ||||
|     return randomString(12); | ||||
| } | ||||
|  | ||||
| function newnoteRevisionId() { | ||||
| function newNoteRevisionId() { | ||||
|     return randomString(12); | ||||
| } | ||||
|  | ||||
| @@ -27,6 +27,10 @@ function newAttributeId() { | ||||
|     return randomString(12); | ||||
| } | ||||
|  | ||||
| function newApiTokenId() { | ||||
|     return randomString(12); | ||||
| } | ||||
|  | ||||
| function randomString(length) { | ||||
|     return randtoken.generate(length); | ||||
| } | ||||
| @@ -126,10 +130,11 @@ module.exports = { | ||||
|     parseDateTime, | ||||
|     newNoteId, | ||||
|     newNoteTreeId, | ||||
|     newnoteRevisionId, | ||||
|     newNoteRevisionId, | ||||
|     newImageId, | ||||
|     newNoteImageId, | ||||
|     newAttributeId, | ||||
|     newApiTokenId, | ||||
|     toBase64, | ||||
|     fromBase64, | ||||
|     hmac, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user