diff --git a/db/migrations/0075__add_api_token.sql b/db/migrations/0075__add_api_token.sql new file mode 100644 index 000000000..3dadbc3f4 --- /dev/null +++ b/db/migrations/0075__add_api_token.sql @@ -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 +); \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index 729a2ca1c..891667c3c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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 +); \ No newline at end of file diff --git a/src/routes/api/image.js b/src/routes/api/image.js index 77a2d1bb8..be9a5dc0a 100644 --- a/src/routes/api/image.js +++ b/src/routes/api/image.js @@ -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; \ No newline at end of file diff --git a/src/routes/api/import.js b/src/routes/api/import.js index 8ac057abb..8857018bb 100644 --- a/src/routes/api/import.js +++ b/src/routes/api/import.js @@ -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(); diff --git a/src/routes/api/sender.js b/src/routes/api/sender.js new file mode 100644 index 000000000..016c2d509 --- /dev/null +++ b/src/routes/api/sender.js @@ -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 = ``; + + await sql.execute("UPDATE notes SET content = ? WHERE noteId = ?", [content, noteId]); + + res.send({}); +})); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/api/sync.js b/src/routes/api/sync.js index 4835e2f5b..922b5c503 100644 --- a/src/routes/api/sync.js +++ b/src/routes/api/sync.js @@ -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; \ No newline at end of file diff --git a/src/routes/routes.js b/src/routes/routes.js index 4c3458659..d8050f9f6 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -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 = { diff --git a/src/services/app_info.js b/src/services/app_info.js index 01050340c..dfac4ab37 100644 --- a/src/services/app_info.js +++ b/src/services/app_info.js @@ -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, diff --git a/src/services/consistency_checks.js b/src/services/consistency_checks.js index 1322b109f..f1a976c22 100644 --- a/src/services/consistency_checks.js +++ b/src/services/consistency_checks.js @@ -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 diff --git a/src/services/image.js b/src/services/image.js new file mode 100644 index 000000000..5626738cf --- /dev/null +++ b/src/services/image.js @@ -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 +}; \ No newline at end of file diff --git a/src/services/notes.js b/src/services/notes.js index efccf2560..fe8390b9b 100644 --- a/src/services/notes.js +++ b/src/services/notes.js @@ -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) { diff --git a/src/services/sync.js b/src/services/sync.js index e9e516d20..7d7e4cb0a 100644 --- a/src/services/sync.js +++ b/src/services/sync.js @@ -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}`); } diff --git a/src/services/sync_table.js b/src/services/sync_table.js index 412f7aa77..b817ef38d 100644 --- a/src/services/sync_table.js +++ b/src/services/sync_table.js @@ -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 diff --git a/src/services/sync_update.js b/src/services/sync_update.js index 7613e1052..31aeb42a7 100644 --- a/src/services/sync_update.js +++ b/src/services/sync_update.js @@ -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 }; \ No newline at end of file diff --git a/src/services/utils.js b/src/services/utils.js index de73533ac..0e5ee19e5 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -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,