Compare commits

...

16 Commits

Author SHA1 Message Date
azivner
780f462e94 release 0.3.0 2018-01-07 10:04:46 -05:00
azivner
488e657cc4 forgotten express-promise-wrap in package.json 2018-01-07 10:04:43 -05:00
azivner
8bc2a21d80 auth exception for images in electron 2018-01-07 09:59:05 -05:00
azivner
743d72a0c3 added express-promise-wrap to catch and respond to unhandled exceptions immediately, previously the requests just hanged 2018-01-07 09:35:44 -05:00
azivner
20b1357be6 gif support 2018-01-07 09:22:55 -05:00
azivner
d9f2bb37e7 PNG alpha channel is converted to white instead of default ugly black 2018-01-07 08:26:42 -05:00
azivner
97c1b3061f correct detection of optimized image format 2018-01-07 08:24:04 -05:00
azivner
c022fcf196 reloading note tree when we have note detail changes 2018-01-06 22:56:54 -05:00
azivner
b5baab056c enter on title will put focus on editor 2018-01-06 22:53:37 -05:00
azivner
edc9a1a2bf creating / updating notes_image rows 2018-01-06 22:38:53 -05:00
azivner
c0e45a73a8 relation between notes and images 2018-01-06 21:49:02 -05:00
azivner
784cd62df1 image sync 2018-01-06 15:56:00 -05:00
azivner
91cf090820 compressing PNGs with pngquant 2018-01-06 13:53:02 -05:00
azivner
d9f29cbf27 resizing and optimizing jpeg images with mozjpeg 2018-01-06 12:38:25 -05:00
azivner
23a5e38e02 added basic support for uploading and serving files 2018-01-05 23:54:02 -05:00
azivner
663bd1a8fe added sync mutex for consistency checks and backup 2018-01-04 21:37:36 -05:00
48 changed files with 2916 additions and 317 deletions

View File

@@ -0,0 +1,11 @@
CREATE TABLE images
(
image_id TEXT PRIMARY KEY NOT NULL,
format TEXT NOT NULL,
checksum TEXT NOT NULL,
name TEXT NOT NULL,
data BLOB,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
);

View File

@@ -0,0 +1,16 @@
DROP TABLE images;
CREATE TABLE images
(
image_id TEXT PRIMARY KEY NOT NULL,
note_id TEXT NOT NULL,
format TEXT NOT NULL,
checksum TEXT NOT NULL,
name TEXT NOT NULL,
data BLOB,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
);
CREATE INDEX images_note_id_index ON images (note_id);

View File

@@ -0,0 +1,27 @@
DROP TABLE images;
CREATE TABLE images
(
image_id TEXT PRIMARY KEY NOT NULL,
format TEXT NOT NULL,
checksum TEXT NOT NULL,
name TEXT NOT NULL,
data BLOB,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
);
CREATE TABLE notes_image
(
note_image_id TEXT PRIMARY KEY NOT NULL,
note_id TEXT NOT NULL,
image_id TEXT NOT NULL,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
);
CREATE INDEX notes_image_note_id_index ON notes_image (note_id);
CREATE INDEX notes_image_image_id_index ON notes_image (image_id);
CREATE INDEX notes_image_note_id_image_id_index ON notes_image (note_id, image_id);

2318
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,12 @@
{
"name": "trilium",
"description": "Trilium Notes",
"version": "0.2.2",
"version": "0.3.0",
"license": "AGPL-3.0-only",
"repository": {
"type": "git",
"url": "https://github.com/zadam/trilium.git"
},
"scripts": {
"start": "node ./bin/www",
"test-electron": "xo",
@@ -14,6 +19,7 @@
"publish-forge": "electron-forge publish"
},
"dependencies": {
"async-mutex": "^0.1.3",
"body-parser": "~1.18.2",
"cookie-parser": "~1.4.3",
"debug": "~3.1.0",
@@ -23,15 +29,24 @@
"electron-debug": "^1.0.0",
"electron-in-page-search": "^1.2.4",
"express": "~4.16.2",
"express-promise-wrap": "^0.2.2",
"express-session": "^1.15.6",
"fs-extra": "^4.0.2",
"helmet": "^3.9.0",
"html": "^1.0.0",
"image-type": "^3.0.0",
"imagemin": "^5.3.1",
"imagemin-giflossy": "^5.1.10",
"imagemin-mozjpeg": "^7.0.0",
"imagemin-pngquant": "^5.0.1",
"ini": "^1.3.4",
"jimp": "^0.2.28",
"multer": "^1.3.0",
"rand-token": "^0.4.0",
"request": "^2.83.0",
"request-promise": "^4.2.2",
"rimraf": "^2.6.2",
"sanitize-filename": "^1.6.1",
"scrypt": "^6.0.3",
"serve-favicon": "~2.4.5",
"session-file-store": "^1.1.2",

View File

@@ -90,6 +90,8 @@ $(document).bind('keydown', "ctrl+shift+down", () => {
return false;
});
$("#note-title").bind('keydown', 'return', () => $("#note-detail").focus());
$(window).on('beforeunload', () => {
// this makes sure that when user e.g. reloads the page or navigates away from the page, the note's content is saved
// this sends the request asynchronously and doesn't wait for result

View File

@@ -29,7 +29,9 @@ const messaging = (function() {
const syncData = message.data.filter(sync => sync.source_id !== glob.sourceId);
if (syncData.some(sync => sync.entity_name === 'notes_tree')) {
if (syncData.some(sync => sync.entity_name === 'notes_tree')
|| syncData.some(sync => sync.entity_name === 'notes')) {
console.log(now(), "Reloading tree because of background changes");
noteTree.reload();
@@ -47,6 +49,9 @@ const messaging = (function() {
recentNotes.reload();
}
// we don't detect image changes here since images themselves are immutable and references should be
// updated in note detail as well
changesToPushCountEl.html(message.changesToPushCount);
}
else if (message.type === 'sync-hash-check-failed') {

View File

@@ -91,6 +91,7 @@ const server = (function() {
get,
post,
put,
remove
remove,
getHeaders
}
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,11 +4,12 @@ const express = require('express');
const router = express.Router();
const anonymization = require('../../services/anonymization');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.post('/anonymize', auth.checkApiAuth, async (req, res, next) => {
router.post('/anonymize', auth.checkApiAuth, wrap(async (req, res, next) => {
await anonymization.anonymize();
res.send({});
});
}));
module.exports = router;

View File

@@ -4,9 +4,10 @@ const express = require('express');
const router = express.Router();
const app_info = require('../../services/app_info');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkApiAuth, async (req, res, next) => {
router.get('', auth.checkApiAuth, wrap(async (req, res, next) => {
res.send(app_info);
});
}));
module.exports = router;

View File

@@ -7,8 +7,9 @@ const utils = require('../../services/utils');
const sync_table = require('../../services/sync_table');
const auth = require('../../services/auth');
const log = require('../../services/log');
const wrap = require('express-promise-wrap').wrap;
router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, async (req, res, next) => {
router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, wrap(async (req, res, next) => {
await sql.doInTransaction(async () => {
const noteIdsToDelete = await sql.getFirstColumn("SELECT note_id FROM notes WHERE is_deleted = 1");
const noteIdsSql = noteIdsToDelete
@@ -34,14 +35,14 @@ router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, async (req, res, n
});
res.send({});
});
}));
router.post('/vacuum-database', auth.checkApiAuth, async (req, res, next) => {
router.post('/vacuum-database', auth.checkApiAuth, wrap(async (req, res, next) => {
await sql.execute("VACUUM");
log.info("Database has been vacuumed.");
res.send({});
});
}));
module.exports = router;

View File

@@ -4,14 +4,15 @@ const express = require('express');
const router = express.Router();
const sql = require('../../services/sql');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkApiAuth, async (req, res, next) => {
router.get('', auth.checkApiAuth, wrap(async (req, res, next) => {
await deleteOld();
const result = await sql.getAll("SELECT * FROM event_log ORDER BY date_added DESC");
res.send(result);
});
}));
async function deleteOld() {
const cutoffId = await sql.getFirstValue("SELECT id FROM event_log ORDER BY id DESC LIMIT 1000, 1");

View File

@@ -8,8 +8,9 @@ const sql = require('../../services/sql');
const data_dir = require('../../services/data_dir');
const html = require('html');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('/:noteId/to/:directory', auth.checkApiAuth, async (req, res, next) => {
router.get('/:noteId/to/:directory', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const directory = req.params.directory.replace(/[^0-9a-zA-Z_-]/gi, '');
@@ -30,7 +31,7 @@ router.get('/:noteId/to/:directory', auth.checkApiAuth, async (req, res, next) =
await exportNote(noteTreeId, completeExportDir);
res.send({});
});
}));
async function exportNote(noteTreeId, dir) {
const noteTree = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId]);

142
routes/api/image.js Normal file
View File

@@ -0,0 +1,142 @@
"use strict";
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 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;
router.get('/:imageId/:filename', auth.checkApiAuthOrElectron, wrap(async (req, res, next) => {
const image = await sql.getFirst("SELECT * FROM images WHERE image_id = ?", [req.params.imageId]);
if (!image) {
return res.status(404).send({});
}
res.set('Content-Type', 'image/' + image.format);
res.send(image.data);
}));
router.post('', auth.checkApiAuthOrElectron, multer.single('upload'), wrap(async (req, res, next) => {
const sourceId = req.headers.source_id;
const noteId = req.query.noteId;
const file = req.file;
const note = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]);
if (!note) {
return res.status(404).send(`Note ${noteId} doesn't exist.`);
}
if (!["image/png", "image/jpeg", "image/gif"].includes(file.mimetype)) {
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", {
image_id: imageId,
format: imageFormat.ext,
name: fileName,
checksum: utils.hash(optimizedImage),
data: optimizedImage,
is_deleted: 0,
date_modified: now,
date_created: now
});
await sync_table.addImageSync(imageId, sourceId);
const noteImageId = utils.newNoteImageId();
await sql.insert("notes_image", {
note_image_id: noteImageId,
note_id: noteId,
image_id: imageId,
is_deleted: 0,
date_modified: now,
date_created: now
});
await sync_table.addNoteImageSync(noteImageId, sourceId);
});
res.send({
uploaded: true,
url: `/api/images/${imageId}/${fileName}`
});
}));
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;

View File

@@ -8,8 +8,9 @@ const data_dir = require('../../services/data_dir');
const utils = require('../../services/utils');
const sync_table = require('../../services/sync_table');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('/:directory/to/:parentNoteId', auth.checkApiAuth, async (req, res, next) => {
router.get('/:directory/to/:parentNoteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const directory = req.params.directory.replace(/[^0-9a-zA-Z_-]/gi, '');
const parentNoteId = req.params.parentNoteId;
@@ -18,7 +19,7 @@ router.get('/:directory/to/:parentNoteId', auth.checkApiAuth, async (req, res, n
await sql.doInTransaction(async () => await importNotes(dir, parentNoteId));
res.send({});
});
}));
async function importNotes(dir, parentNoteId) {
const parent = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [parentNoteId]);

View File

@@ -9,8 +9,9 @@ const auth = require('../../services/auth');
const password_encryption = require('../../services/password_encryption');
const protected_session = require('../../services/protected_session');
const app_info = require('../../services/app_info');
const wrap = require('express-promise-wrap').wrap;
router.post('/sync', async (req, res, next) => {
router.post('/sync', wrap(async (req, res, next) => {
const timestampStr = req.body.timestamp;
const timestamp = utils.parseDate(timestampStr);
@@ -44,10 +45,10 @@ router.post('/sync', async (req, res, next) => {
res.send({
sourceId: source_id.getCurrentSourceId()
});
});
}));
// this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username)
router.post('/protected', auth.checkApiAuth, async (req, res, next) => {
router.post('/protected', auth.checkApiAuth, wrap(async (req, res, next) => {
const password = req.body.password;
if (!await password_encryption.verifyPassword(password)) {
@@ -67,6 +68,6 @@ router.post('/protected', auth.checkApiAuth, async (req, res, next) => {
success: true,
protectedSessionId: protectedSessionId
});
});
}));
module.exports = router;

View File

@@ -6,20 +6,21 @@ const auth = require('../../services/auth');
const options = require('../../services/options');
const migration = require('../../services/migration');
const app_info = require('../../services/app_info');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkApiAuthForMigrationPage, async (req, res, next) => {
router.get('', auth.checkApiAuthForMigrationPage, wrap(async (req, res, next) => {
res.send({
db_version: parseInt(await options.getOption('db_version')),
app_db_version: app_info.db_version
});
});
}));
router.post('', auth.checkApiAuthForMigrationPage, async (req, res, next) => {
router.post('', auth.checkApiAuthForMigrationPage, wrap(async (req, res, next) => {
const migrations = await migration.migrate();
res.send({
migrations: migrations
});
});
}));
module.exports = router;

View File

@@ -7,8 +7,9 @@ const auth = require('../../services/auth');
const data_encryption = require('../../services/data_encryption');
const protected_session = require('../../services/protected_session');
const sync_table = require('../../services/sync_table');
const wrap = require('express-promise-wrap').wrap;
router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => {
router.get('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const history = await sql.getAll("SELECT * FROM notes_history WHERE note_id = ? order by date_modified_to desc", [noteId]);
@@ -22,9 +23,9 @@ router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => {
}
res.send(history);
});
}));
router.put('', auth.checkApiAuth, async (req, res, next) => {
router.put('', auth.checkApiAuth, wrap(async (req, res, next) => {
const sourceId = req.headers.source_id;
await sql.doInTransaction(async () => {
@@ -34,6 +35,6 @@ router.put('', auth.checkApiAuth, async (req, res, next) => {
});
res.send();
});
}));
module.exports = router;

View File

@@ -8,8 +8,9 @@ const notes = require('../../services/notes');
const log = require('../../services/log');
const protected_session = require('../../services/protected_session');
const data_encryption = require('../../services/data_encryption');
const wrap = require('express-promise-wrap').wrap;
router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => {
router.get('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const detail = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]);
@@ -30,9 +31,9 @@ router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => {
res.send({
detail: detail
});
});
}));
router.post('/:parentNoteId/children', auth.checkApiAuth, async (req, res, next) => {
router.post('/:parentNoteId/children', auth.checkApiAuth, wrap(async (req, res, next) => {
const sourceId = req.headers.source_id;
const parentNoteId = req.params.parentNoteId;
const note = req.body;
@@ -43,9 +44,9 @@ router.post('/:parentNoteId/children', auth.checkApiAuth, async (req, res, next)
'note_id': noteId,
'note_tree_id': noteTreeId
});
});
}));
router.put('/:noteId', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const note = req.body;
const noteId = req.params.noteId;
const sourceId = req.headers.source_id;
@@ -54,17 +55,17 @@ router.put('/:noteId', auth.checkApiAuth, async (req, res, next) => {
await notes.updateNote(noteId, note, dataKey, sourceId);
res.send({});
});
}));
router.delete('/:noteTreeId', auth.checkApiAuth, async (req, res, next) => {
router.delete('/:noteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
await sql.doInTransaction(async () => {
await notes.deleteNote(req.params.noteTreeId, req.headers.source_id);
});
res.send({});
});
}));
router.get('/', auth.checkApiAuth, async (req, res, next) => {
router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const search = '%' + req.query.search + '%';
const result = await sql.getAll("SELECT note_id FROM notes WHERE note_title LIKE ? OR note_text LIKE ?", [search, search]);
@@ -76,6 +77,6 @@ router.get('/', auth.checkApiAuth, async (req, res, next) => {
}
res.send(noteIdList);
});
}));
module.exports = router;

View File

@@ -6,13 +6,14 @@ const sql = require('../../services/sql');
const auth = require('../../services/auth');
const utils = require('../../services/utils');
const sync_table = require('../../services/sync_table');
const wrap = require('express-promise-wrap').wrap;
/**
* Code in this file deals with moving and cloning note tree rows. Relationship between note and parent note is unique
* for not deleted note trees. There may be multiple deleted note-parent note relationships.
*/
router.put('/:noteTreeId/move-to/:parentNoteId', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteTreeId/move-to/:parentNoteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const parentNoteId = req.params.parentNoteId;
const sourceId = req.headers.source_id;
@@ -36,9 +37,9 @@ router.put('/:noteTreeId/move-to/:parentNoteId', auth.checkApiAuth, async (req,
});
res.send({ success: true });
});
}));
router.put('/:noteTreeId/move-before/:beforeNoteTreeId', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteTreeId/move-before/:beforeNoteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const beforeNoteTreeId = req.params.beforeNoteTreeId;
const sourceId = req.headers.source_id;
@@ -67,9 +68,9 @@ router.put('/:noteTreeId/move-before/:beforeNoteTreeId', auth.checkApiAuth, asyn
});
res.send({ success: true });
});
}));
router.put('/:noteTreeId/move-after/:afterNoteTreeId', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteTreeId/move-after/:afterNoteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const afterNoteTreeId = req.params.afterNoteTreeId;
const sourceId = req.headers.source_id;
@@ -96,9 +97,9 @@ router.put('/:noteTreeId/move-after/:afterNoteTreeId', auth.checkApiAuth, async
});
res.send({ success: true });
});
}));
router.put('/:childNoteId/clone-to/:parentNoteId', auth.checkApiAuth, async (req, res, next) => {
router.put('/:childNoteId/clone-to/:parentNoteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const parentNoteId = req.params.parentNoteId;
const childNoteId = req.params.childNoteId;
const prefix = req.body.prefix;
@@ -131,9 +132,9 @@ router.put('/:childNoteId/clone-to/:parentNoteId', auth.checkApiAuth, async (req
});
res.send({ success: true });
});
}));
router.put('/:noteId/clone-after/:afterNoteTreeId', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteId/clone-after/:afterNoteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const afterNoteTreeId = req.params.afterNoteTreeId;
const sourceId = req.headers.source_id;
@@ -168,7 +169,7 @@ router.put('/:noteId/clone-after/:afterNoteTreeId', auth.checkApiAuth, async (re
});
res.send({ success: true });
});
}));
async function loadSubTreeNoteIds(parentNoteId, subTreeNoteIds) {
subTreeNoteIds.push(parentNoteId);
@@ -190,7 +191,7 @@ async function validateParentChild(res, parentNoteId, childNoteId, noteTreeId =
if (existing && (noteTreeId === null || existing.note_tree_id !== noteTreeId)) {
res.send({
success: false,
message: 'This note already exists in target parent note.'
message: 'This note already exists in the target.'
});
return false;
@@ -246,7 +247,7 @@ async function checkTreeCycle(parentNoteId, childNoteId) {
return await checkTreeCycleInner(parentNoteId);
}
router.put('/:noteTreeId/expanded/:expanded', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteTreeId/expanded/:expanded', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const expanded = req.params.expanded;
@@ -257,6 +258,6 @@ router.put('/:noteTreeId/expanded/:expanded', auth.checkApiAuth, async (req, res
});
res.send({});
});
}));
module.exports = router;

View File

@@ -5,11 +5,12 @@ const router = express.Router();
const sql = require('../../services/sql');
const changePassword = require('../../services/change_password');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.post('/change', auth.checkApiAuth, async (req, res, next) => {
router.post('/change', auth.checkApiAuth, wrap(async (req, res, next) => {
const result = await changePassword.changePassword(req.body['current_password'], req.body['new_password'], req);
res.send(result);
});
}));
module.exports = router;

View File

@@ -4,8 +4,9 @@ const express = require('express');
const router = express.Router();
const sql = require('../../services/sql');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('/', auth.checkApiAuth, async (req, res, next) => {
router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const recentChanges = await sql.getAll(
`SELECT
notes.is_deleted AS current_is_deleted,
@@ -19,6 +20,6 @@ router.get('/', auth.checkApiAuth, async (req, res, next) => {
LIMIT 1000`);
res.send(recentChanges);
});
}));
module.exports = router;

View File

@@ -7,12 +7,13 @@ const auth = require('../../services/auth');
const utils = require('../../services/utils');
const sync_table = require('../../services/sync_table');
const options = require('../../services/options');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkApiAuth, async (req, res, next) => {
router.get('', auth.checkApiAuth, wrap(async (req, res, next) => {
res.send(await getRecentNotes());
});
}));
router.put('/:noteTreeId/:notePath', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteTreeId/:notePath', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const notePath = req.params.notePath;
const sourceId = req.headers.source_id;
@@ -31,7 +32,7 @@ router.put('/:noteTreeId/:notePath', auth.checkApiAuth, async (req, res, next) =
});
res.send(await getRecentNotes());
});
}));
async function getRecentNotes() {
return await sql.getAll(`

View File

@@ -5,24 +5,25 @@ const router = express.Router();
const sql = require('../../services/sql');
const options = require('../../services/options');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
// options allowed to be updated directly in settings dialog
const ALLOWED_OPTIONS = ['protected_session_timeout', 'history_snapshot_time_interval'];
router.get('/all', auth.checkApiAuth, async (req, res, next) => {
router.get('/all', auth.checkApiAuth, wrap(async (req, res, next) => {
const settings = await sql.getMap("SELECT opt_name, opt_value FROM options");
res.send(settings);
});
}));
router.get('/', auth.checkApiAuth, async (req, res, next) => {
router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const settings = await sql.getMap("SELECT opt_name, opt_value FROM options WHERE opt_name IN ("
+ ALLOWED_OPTIONS.map(x => '?').join(",") + ")", ALLOWED_OPTIONS);
res.send(settings);
});
}));
router.post('/', auth.checkApiAuth, async (req, res, next) => {
router.post('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const body = req.body;
const sourceId = req.headers.source_id;
@@ -38,6 +39,6 @@ router.post('/', auth.checkApiAuth, async (req, res, next) => {
else {
res.send("not allowed option to set");
}
});
}));
module.exports = router;

View File

@@ -8,8 +8,9 @@ const sql = require('../../services/sql');
const utils = require('../../services/utils');
const my_scrypt = require('../../services/my_scrypt');
const password_encryption = require('../../services/password_encryption');
const wrap = require('express-promise-wrap').wrap;
router.post('', auth.checkAppNotInitialized, async (req, res, next) => {
router.post('', auth.checkAppNotInitialized, wrap(async (req, res, next) => {
const { username, password } = req.body;
await sql.doInTransaction(async () => {
@@ -27,6 +28,6 @@ router.post('', auth.checkAppNotInitialized, async (req, res, next) => {
sql.setDbReadyAsResolved();
res.send({});
});
}));
module.exports = router;

View File

@@ -4,8 +4,9 @@ const express = require('express');
const router = express.Router();
const auth = require('../../services/auth');
const sql = require('../../services/sql');
const wrap = require('express-promise-wrap').wrap;
router.post('/execute', auth.checkApiAuth, async (req, res, next) => {
router.post('/execute', auth.checkApiAuth, wrap(async (req, res, next) => {
const query = req.body.query;
try {
@@ -20,6 +21,6 @@ router.post('/execute', auth.checkApiAuth, async (req, res, next) => {
error: e.message
});
}
});
}));
module.exports = router;

View File

@@ -10,19 +10,20 @@ const sql = require('../../services/sql');
const options = require('../../services/options');
const content_hash = require('../../services/content_hash');
const log = require('../../services/log');
const wrap = require('express-promise-wrap').wrap;
router.get('/check', auth.checkApiAuth, async (req, res, next) => {
router.get('/check', auth.checkApiAuth, wrap(async (req, res, next) => {
res.send({
'hashes': await content_hash.getHashes(),
'max_sync_id': await sql.getFirstValue('SELECT MAX(id) FROM sync')
});
});
}));
router.post('/now', auth.checkApiAuth, async (req, res, next) => {
router.post('/now', auth.checkApiAuth, wrap(async (req, res, next) => {
res.send(await sync.sync());
});
}));
router.post('/fill-sync-rows', auth.checkApiAuth, async (req, res, next) => {
router.post('/fill-sync-rows', auth.checkApiAuth, wrap(async (req, res, next) => {
await sql.doInTransaction(async () => {
await sync_table.fillAllSyncRows();
});
@@ -30,9 +31,9 @@ router.post('/fill-sync-rows', auth.checkApiAuth, async (req, res, next) => {
log.info("Sync rows have been filled.");
res.send({});
});
}));
router.post('/force-full-sync', auth.checkApiAuth, async (req, res, next) => {
router.post('/force-full-sync', auth.checkApiAuth, wrap(async (req, res, next) => {
await sql.doInTransaction(async () => {
await options.setOption('last_synced_pull', 0);
await options.setOption('last_synced_push', 0);
@@ -44,9 +45,9 @@ router.post('/force-full-sync', auth.checkApiAuth, async (req, res, next) => {
sync.sync();
res.send({});
});
}));
router.post('/force-note-sync/:noteId', auth.checkApiAuth, async (req, res, next) => {
router.post('/force-note-sync/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
await sql.doInTransaction(async () => {
@@ -68,35 +69,35 @@ router.post('/force-note-sync/:noteId', auth.checkApiAuth, async (req, res, next
sync.sync();
res.send({});
});
}));
router.get('/changed', auth.checkApiAuth, async (req, res, next) => {
router.get('/changed', auth.checkApiAuth, wrap(async (req, res, next) => {
const lastSyncId = parseInt(req.query.lastSyncId);
res.send(await sql.getAll("SELECT * FROM sync WHERE id > ?", [lastSyncId]));
});
}));
router.get('/notes/:noteId', auth.checkApiAuth, async (req, res, next) => {
router.get('/notes/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
res.send({
entity: await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId])
});
});
}));
router.get('/notes_tree/:noteTreeId', auth.checkApiAuth, async (req, res, next) => {
router.get('/notes_tree/:noteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
res.send(await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId]));
});
}));
router.get('/notes_history/:noteHistoryId', auth.checkApiAuth, async (req, res, next) => {
router.get('/notes_history/:noteHistoryId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteHistoryId = req.params.noteHistoryId;
res.send(await sql.getFirst("SELECT * FROM notes_history WHERE note_history_id = ?", [noteHistoryId]));
});
}));
router.get('/options/:optName', auth.checkApiAuth, async (req, res, next) => {
router.get('/options/:optName', auth.checkApiAuth, wrap(async (req, res, next) => {
const optName = req.params.optName;
if (!options.SYNCED_OPTIONS.includes(optName)) {
@@ -105,57 +106,86 @@ router.get('/options/:optName', auth.checkApiAuth, async (req, res, next) => {
else {
res.send(await sql.getFirst("SELECT * FROM options WHERE opt_name = ?", [optName]));
}
});
}));
router.get('/notes_reordering/:noteTreeParentId', auth.checkApiAuth, async (req, res, next) => {
router.get('/notes_reordering/:noteTreeParentId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeParentId = req.params.noteTreeParentId;
res.send({
parent_note_id: noteTreeParentId,
ordering: await sql.getMap("SELECT note_tree_id, note_position FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0", [noteTreeParentId])
});
});
}));
router.get('/recent_notes/:noteTreeId', auth.checkApiAuth, async (req, res, next) => {
router.get('/recent_notes/:noteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
res.send(await sql.getFirst("SELECT * FROM recent_notes WHERE note_tree_id = ?", [noteTreeId]));
});
}));
router.put('/notes', auth.checkApiAuth, async (req, res, next) => {
router.get('/images/:imageId', auth.checkApiAuth, wrap(async (req, res, next) => {
const imageId = req.params.imageId;
const entity = await sql.getFirst("SELECT * FROM images WHERE image_id = ?", [imageId]);
if (entity && entity.data !== null) {
entity.data = entity.data.toString('base64');
}
res.send(entity);
}));
router.get('/notes_image/:noteImageId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteImageId = req.params.noteImageId;
res.send(await sql.getFirst("SELECT * FROM notes_image WHERE note_image_id = ?", [noteImageId]));
}));
router.put('/notes', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNote(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/notes_tree', auth.checkApiAuth, async (req, res, next) => {
router.put('/notes_tree', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNoteTree(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/notes_history', auth.checkApiAuth, async (req, res, next) => {
router.put('/notes_history', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNoteHistory(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/notes_reordering', auth.checkApiAuth, async (req, res, next) => {
router.put('/notes_reordering', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNoteReordering(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/options', auth.checkApiAuth, async (req, res, next) => {
router.put('/options', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateOptions(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/recent_notes', auth.checkApiAuth, async (req, res, next) => {
router.put('/recent_notes', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateRecentNotes(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/images', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateImage(req.body.entity, req.body.sourceId);
res.send({});
}));
router.put('/notes_image', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNoteImage(req.body.entity, req.body.sourceId);
res.send({});
}));
module.exports = router;

View File

@@ -10,8 +10,9 @@ const protected_session = require('../../services/protected_session');
const data_encryption = require('../../services/data_encryption');
const notes = require('../../services/notes');
const sync_table = require('../../services/sync_table');
const wrap = require('express-promise-wrap').wrap;
router.get('/', auth.checkApiAuth, async (req, res, next) => {
router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const notes = await sql.getAll(`
SELECT
notes_tree.*,
@@ -39,9 +40,9 @@ router.get('/', auth.checkApiAuth, async (req, res, next) => {
notes: notes,
start_note_path: await options.getOption('start_note_path')
});
});
}));
router.put('/:noteId/protect-sub-tree/:isProtected', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteId/protect-sub-tree/:isProtected', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const isProtected = !!parseInt(req.params.isProtected);
const dataKey = protected_session.getDataKey(req);
@@ -52,9 +53,9 @@ router.put('/:noteId/protect-sub-tree/:isProtected', auth.checkApiAuth, async (r
});
res.send({});
});
}));
router.put('/:noteTreeId/set-prefix', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteTreeId/set-prefix', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const sourceId = req.headers.source_id;
const prefix = utils.isEmptyOrWhitespace(req.body.prefix) ? null : req.body.prefix;
@@ -66,6 +67,6 @@ router.put('/:noteTreeId/set-prefix', auth.checkApiAuth, async (req, res, next)
});
res.send({});
});
}));
module.exports = router;

View File

@@ -5,12 +5,13 @@ const router = express.Router();
const auth = require('../services/auth');
const source_id = require('../services/source_id');
const sql = require('../services/sql');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkAuth, async (req, res, next) => {
router.get('', auth.checkAuth, wrap(async (req, res, next) => {
res.render('index', {
sourceId: await source_id.generateSourceId(),
maxSyncIdAtLoad: await sql.getFirstValue("SELECT MAX(id) FROM sync")
});
});
}));
module.exports = router;

View File

@@ -5,12 +5,13 @@ const router = express.Router();
const utils = require('../services/utils');
const options = require('../services/options');
const my_scrypt = require('../services/my_scrypt');
const wrap = require('express-promise-wrap').wrap;
router.get('', (req, res, next) => {
router.get('', wrap(async (req, res, next) => {
res.render('login', { 'failedAuth': false });
});
}));
router.post('', async (req, res, next) => {
router.post('', wrap(async (req, res, next) => {
const userName = await options.getOption('username');
const guessedPassword = req.body.password;
@@ -32,7 +33,7 @@ router.post('', async (req, res, next) => {
else {
res.render('login', {'failedAuth': true});
}
});
}));
async function verifyPassword(guessed_password) {

View File

@@ -2,14 +2,15 @@
const express = require('express');
const router = express.Router();
const wrap = require('express-promise-wrap').wrap;
router.post('', async (req, res, next) => {
router.post('', wrap(async (req, res, next) => {
req.session.regenerate(() => {
req.session.loggedIn = false;
res.redirect('/');
});
});
}));
module.exports = router;

View File

@@ -3,9 +3,10 @@
const express = require('express');
const router = express.Router();
const auth = require('../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkAuthForMigrationPage, (req, res, next) => {
router.get('', auth.checkAuthForMigrationPage, wrap(async (req, res, next) => {
res.render('migration', {});
});
}));
module.exports = router;

View File

@@ -24,6 +24,7 @@ const setupApiRoute = require('./api/setup');
const sqlRoute = require('./api/sql');
const anonymizationRoute = require('./api/anonymization');
const cleanupRoute = require('./api/cleanup');
const imageRoute = require('./api/image');
function register(app) {
app.use('/', indexRoute);
@@ -51,6 +52,7 @@ function register(app) {
app.use('/api/sql', sqlRoute);
app.use('/api/anonymization', anonymizationRoute);
app.use('/api/cleanup', cleanupRoute);
app.use('/api/images', imageRoute);
}
module.exports = {

View File

@@ -3,9 +3,10 @@
const express = require('express');
const router = express.Router();
const auth = require('../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkAppNotInitialized, (req, res, next) => {
router.get('', auth.checkAppNotInitialized, wrap(async (req, res, next) => {
res.render('setup', {});
});
}));
module.exports = router;

View File

@@ -3,7 +3,7 @@
const build = require('./build');
const packageJson = require('../package');
const APP_DB_VERSION = 62;
const APP_DB_VERSION = 65;
module.exports = {
app_version: packageJson.version,

View File

@@ -28,6 +28,20 @@ async function checkAuthForMigrationPage(req, res, next) {
}
}
// for electron things which need network stuff
// currently we're doing that for file upload because handling form data seems to be difficult
async function checkApiAuthOrElectron(req, res, next) {
if (!req.session.loggedIn && !utils.isElectron()) {
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
}
}
async function checkApiAuth(req, res, next) {
if (!req.session.loggedIn) {
res.status(401).send("Not authorized");
@@ -63,5 +77,6 @@ module.exports = {
checkAuthForMigrationPage,
checkApiAuth,
checkApiAuthForMigrationPage,
checkAppNotInitialized
checkAppNotInitialized,
checkApiAuthOrElectron
};

View File

@@ -6,6 +6,7 @@ const fs = require('fs-extra');
const dataDir = require('./data_dir');
const log = require('./log');
const sql = require('./sql');
const sync_mutex = require('./sync_mutex');
async function regularBackup() {
const now = new Date();
@@ -21,17 +22,25 @@ async function regularBackup() {
}
async function backupNow() {
const now = utils.nowDate();
// we don't want to backup DB in the middle of sync with potentially inconsistent DB state
const releaseMutex = await sync_mutex.acquire();
const backupFile = dataDir.BACKUP_DIR + "/" + "backup-" + utils.getDateTimeForFile() + ".db";
try {
const now = utils.nowDate();
fs.copySync(dataDir.DOCUMENT_PATH, backupFile);
const backupFile = dataDir.BACKUP_DIR + "/" + "backup-" + utils.getDateTimeForFile() + ".db";
log.info("Created backup at " + backupFile);
fs.copySync(dataDir.DOCUMENT_PATH, backupFile);
await sql.doInTransaction(async () => {
await options.setOption('last_backup_date', now);
});
log.info("Created backup at " + backupFile);
await sql.doInTransaction(async () => {
await options.setOption('last_backup_date', now);
});
}
finally {
releaseMutex();
}
}
async function cleanupOldBackups() {

View File

@@ -1 +1 @@
module.exports = { build_date:"2018-01-03T23:05:00-05:00", build_revision: "f2aaf8b0a3b761fb6a1ec79e7c6b95e3eb9e4db0" };
module.exports = { build_date:"2018-01-07T10:04:46-05:00", build_revision: "488e657cc43fab879662a6da23b5a69dd7591b06" };

View File

@@ -3,8 +3,12 @@
const sql = require('./sql');
const log = require('./log');
const messaging = require('./messaging');
const sync_mutex = require('./sync_mutex');
const utils = require('./utils');
async function runCheck(query, errorText, errorList) {
utils.assertArguments(query, errorText, errorList);
const result = await sql.getFirstColumn(query);
if (result.length > 0) {
@@ -80,11 +84,9 @@ async function runSyncRowChecks(table, key, errorList) {
`Missing ${table} records for existing sync rows`, errorList);
}
async function runChecks() {
async function runAllChecks() {
const errorList = [];
const startTime = new Date();
await runCheck(`
SELECT
note_id
@@ -139,7 +141,7 @@ async function runChecks() {
WHERE
(SELECT COUNT(*) FROM notes_tree WHERE notes.note_id = notes_tree.note_id AND notes_tree.is_deleted = 0) = 0
AND notes.is_deleted = 0
`, );
`, 'No undeleted note trees for note IDs', errorList);
await runCheck(`
SELECT
@@ -175,10 +177,22 @@ async function runChecks() {
COUNT(*) > 1`,
"Duplicate undeleted parent note <-> note relationship - parent note ID > note ID", errorList);
await runCheck(`
SELECT
images.image_id
FROM
images
LEFT JOIN notes_image ON notes_image.image_id = images.image_id
WHERE
notes_image.note_image_id IS NULL`,
"Image with no note relation", errorList);
await runSyncRowChecks("notes", "note_id", errorList);
await runSyncRowChecks("notes_history", "note_history_id", errorList);
await runSyncRowChecks("notes_tree", "note_tree_id", errorList);
await runSyncRowChecks("recent_notes", "note_tree_id", errorList);
await runSyncRowChecks("images", "image_id", errorList);
await runSyncRowChecks("notes_image", "note_image_id", errorList);
if (errorList.length === 0) {
// we run this only if basic checks passed since this assumes basic data consistency
@@ -186,7 +200,24 @@ async function runChecks() {
await checkTreeCycles(errorList);
}
const elapsedTimeMs = new Date().getTime() - startTime.getTime();
return errorList;
}
async function runChecks() {
let errorList;
let elapsedTimeMs;
const releaseMutex = await sync_mutex.acquire();
try {
const startTime = new Date();
errorList = await runAllChecks();
elapsedTimeMs = new Date().getTime() - startTime.getTime();
}
finally {
releaseMutex();
}
if (errorList.length > 0) {
log.info(`Consistency checks failed (took ${elapsedTimeMs}ms) with these errors: ` + JSON.stringify(errorList));

View File

@@ -19,51 +19,70 @@ async function getHashes() {
const optionsQuestionMarks = Array(options.SYNCED_OPTIONS.length).fill('?').join(',');
const hashes = {
notes: getHash(await sql.getAll(`SELECT
note_id,
note_title,
note_text,
date_modified,
is_protected,
is_deleted
FROM notes
ORDER BY note_id`)),
notes: getHash(await sql.getAll(`
SELECT
note_id,
note_title,
note_text,
date_modified,
is_protected,
is_deleted
FROM notes
ORDER BY note_id`)),
notes_tree: getHash(await sql.getAll(`SELECT
note_tree_id,
note_id,
parent_note_id,
note_position,
date_modified,
is_deleted,
prefix
FROM notes_tree
ORDER BY note_tree_id`)),
notes_tree: getHash(await sql.getAll(`
SELECT
note_tree_id,
note_id,
parent_note_id,
note_position,
date_modified,
is_deleted,
prefix
FROM notes_tree
ORDER BY note_tree_id`)),
notes_history: getHash(await sql.getAll(`SELECT
note_history_id,
note_id,
note_title,
note_text,
date_modified_from,
date_modified_to
FROM notes_history
ORDER BY note_history_id`)),
notes_history: getHash(await sql.getAll(`
SELECT
note_history_id,
note_id,
note_title,
note_text,
date_modified_from,
date_modified_to
FROM notes_history
ORDER BY note_history_id`)),
recent_notes: getHash(await sql.getAll(`SELECT
note_tree_id,
note_path,
date_accessed,
is_deleted
FROM recent_notes
ORDER BY note_path`)),
recent_notes: getHash(await sql.getAll(`
SELECT
note_tree_id,
note_path,
date_accessed,
is_deleted
FROM recent_notes
ORDER BY note_path`)),
options: getHash(await sql.getAll(`SELECT
opt_name,
opt_value
FROM options
WHERE opt_name IN (${optionsQuestionMarks})
ORDER BY opt_name`, options.SYNCED_OPTIONS))
options: getHash(await sql.getAll(`
SELECT
opt_name,
opt_value
FROM options
WHERE opt_name IN (${optionsQuestionMarks})
ORDER BY opt_name`, options.SYNCED_OPTIONS)),
// we don't include image data on purpose because they are quite large, checksum is good enough
// to represent the data anyway
images: getHash(await sql.getAll(`
SELECT
image_id,
format,
checksum,
name,
is_deleted,
date_modified,
date_created
FROM images
ORDER BY image_id`))
};
const elapseTimeMs = new Date().getTime() - startTime.getTime();

View File

@@ -135,6 +135,76 @@ async function protectNoteHistory(noteId, dataKey, protect, sourceId) {
}
}
async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) {
const oldNote = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]);
if (oldNote.is_protected) {
decryptNote(oldNote, dataKey);
}
const newNoteHistoryId = utils.newNoteHistoryId();
await sql.insert('notes_history', {
note_history_id: newNoteHistoryId,
note_id: noteId,
// title and text should be decrypted now
note_title: oldNote.note_title,
note_text: oldNote.note_text,
is_protected: 0, // will be fixed in the protectNoteHistory() call
date_modified_from: oldNote.date_modified,
date_modified_to: nowStr
});
await sync_table.addNoteHistorySync(newNoteHistoryId, sourceId);
}
async function saveNoteImages(noteId, noteText, sourceId) {
const existingNoteImages = await sql.getAll("SELECT * FROM notes_image WHERE note_id = ?", [noteId]);
const foundImageIds = [];
const now = utils.nowDate();
const re = /src="\/api\/images\/([a-zA-Z0-9]+)\//g;
let match;
while (match = re.exec(noteText)) {
const imageId = match[1];
const existingNoteImage = existingNoteImages.find(ni => ni.image_id === imageId);
if (!existingNoteImage) {
const noteImageId = utils.newNoteImageId();
await sql.insert("notes_image", {
note_image_id: noteImageId,
note_id: noteId,
image_id: imageId,
is_deleted: 0,
date_modified: now,
date_created: now
});
await sync_table.addNoteImageSync(noteImageId, sourceId);
}
else if (existingNoteImage.is_deleted) {
await sql.execute("UPDATE notes_image SET is_deleted = 0, date_modified = ? WHERE note_image_id = ?",
[now, existingNoteImage.note_image_id]);
await sync_table.addNoteImageSync(existingNoteImage.note_image_id, sourceId);
}
// else we don't need to do anything
foundImageIds.push(imageId);
}
// marking note images as deleted if they are not present on the page anymore
const unusedNoteImages = existingNoteImages.filter(ni => !foundImageIds.includes(ni.image_id));
for (const unusedNoteImage of unusedNoteImages) {
await sql.execute("UPDATE notes_image SET is_deleted = 1, date_modified = ? WHERE note_image_id = ?",
[now, unusedNoteImage.note_image_id]);
await sync_table.addNoteImageSync(unusedNoteImage.note_image_id, sourceId);
}
}
async function updateNote(noteId, newNote, dataKey, sourceId) {
if (newNote.detail.is_protected) {
await encryptNote(newNote, dataKey);
@@ -154,28 +224,11 @@ async function updateNote(noteId, newNote, dataKey, sourceId) {
const msSinceDateCreated = now.getTime() - utils.parseDate(newNote.detail.date_created).getTime();
if (!existingNoteHistoryId && msSinceDateCreated >= historySnapshotTimeInterval * 1000) {
const oldNote = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]);
if (oldNote.is_protected) {
decryptNote(oldNote, dataKey);
}
const newNoteHistoryId = utils.newNoteHistoryId();
await sql.insert('notes_history', {
note_history_id: newNoteHistoryId,
note_id: noteId,
// title and text should be decrypted now
note_title: oldNote.note_title,
note_text: oldNote.note_text,
is_protected: 0, // will be fixed in the protectNoteHistory() call
date_modified_from: oldNote.date_modified,
date_modified_to: nowStr
});
await sync_table.addNoteHistorySync(newNoteHistoryId, sourceId);
await saveNoteHistory(noteId, dataKey, sourceId, nowStr);
}
await saveNoteImages(noteId, newNote.detail.note_text, sourceId);
await protectNoteHistory(noteId, dataKey, newNote.detail.is_protected);
await sql.execute("UPDATE notes SET note_title = ?, note_text = ?, is_protected = ?, date_modified = ? WHERE note_id = ?", [

View File

@@ -14,22 +14,13 @@ const fs = require('fs');
const app_info = require('./app_info');
const messaging = require('./messaging');
const sync_setup = require('./sync_setup');
const sync_mutex = require('./sync_mutex');
let syncInProgress = false;
let proxyToggle = true;
let syncServerCertificate = null;
async function sync() {
if (syncInProgress) {
log.info("Sync already in progress");
return {
success: false,
message: "Sync already in progress"
};
}
syncInProgress = true;
const releaseMutex = await sync_mutex.acquire();
try {
if (!await sql.isDbUpToDate()) {
@@ -74,7 +65,7 @@ async function sync() {
}
}
finally {
syncInProgress = false;
releaseMutex();
}
}
@@ -152,6 +143,12 @@ async function pullSync(syncContext) {
else if (sync.entity_name === 'recent_notes') {
await syncUpdate.updateRecentNotes(resp, syncContext.sourceId);
}
else if (sync.entity_name === 'images') {
await syncUpdate.updateImage(resp, syncContext.sourceId);
}
else if (sync.entity_name === 'notes_image') {
await syncUpdate.updateNoteImage(resp, syncContext.sourceId);
}
else {
throw new Error(`Unrecognized entity type ${sync.entity_name} in sync #${sync.id}`);
}
@@ -223,6 +220,16 @@ async function pushEntity(sync, syncContext) {
else if (sync.entity_name === 'recent_notes') {
entity = await sql.getFirst('SELECT * FROM recent_notes WHERE note_tree_id = ?', [sync.entity_id]);
}
else if (sync.entity_name === 'images') {
entity = await sql.getFirst('SELECT * FROM images WHERE image_id = ?', [sync.entity_id]);
if (entity.data !== null) {
entity.data = entity.data.toString('base64');
}
}
else if (sync.entity_name === 'notes_image') {
entity = await sql.getFirst('SELECT * FROM notes_image WHERE note_image_id = ?', [sync.entity_id]);
}
else {
throw new Error(`Unrecognized entity type ${sync.entity_name} in sync #${sync.id}`);
}

8
services/sync_mutex.js Normal file
View File

@@ -0,0 +1,8 @@
/**
* Sync makes process can make data intermittently inconsistent. Processes which require strong data consistency
* (like consistency checks) can use this mutex to make sure sync isn't currently running.
*/
const Mutex = require('async-mutex').Mutex;
module.exports = new Mutex();

View File

@@ -28,6 +28,14 @@ async function addRecentNoteSync(noteTreeId, sourceId) {
await addEntitySync("recent_notes", noteTreeId, sourceId);
}
async function addImageSync(imageId, sourceId) {
await addEntitySync("images", imageId, sourceId);
}
async function addNoteImageSync(noteImageId, sourceId) {
await addEntitySync("notes_image", noteImageId, sourceId);
}
async function addEntitySync(entityName, entityId, sourceId) {
await sql.replace("sync", {
entity_name: entityName,
@@ -78,6 +86,8 @@ async function fillAllSyncRows() {
await fillSyncRows("notes_tree", "note_tree_id");
await fillSyncRows("notes_history", "note_history_id");
await fillSyncRows("recent_notes", "note_tree_id");
await fillSyncRows("images", "image_id");
await fillSyncRows("notes_image", "note_image_id");
}
module.exports = {
@@ -87,6 +97,8 @@ module.exports = {
addNoteHistorySync,
addOptionsSync,
addRecentNoteSync,
addImageSync,
addNoteImageSync,
cleanupSyncRowsForMissingEntities,
fillAllSyncRows
};

View File

@@ -92,11 +92,45 @@ async function updateRecentNotes(entity, sourceId) {
}
}
async function updateImage(entity, sourceId) {
if (entity.data !== null) {
entity.data = Buffer.from(entity.data, 'base64');
}
const origImage = await sql.getFirst("SELECT * FROM images WHERE image_id = ?", [entity.image_id]);
if (!origImage || origImage.date_modified <= entity.date_modified) {
await sql.doInTransaction(async () => {
await sql.replace("images", entity);
await sync_table.addImageSync(entity.image_id, sourceId);
});
log.info("Update/sync image " + entity.image_id);
}
}
async function updateNoteImage(entity, sourceId) {
const origNoteImage = await sql.getFirst("SELECT * FROM notes_image WHERE note_image_id = ?", [entity.note_image_id]);
if (!origNoteImage || origNoteImage.date_modified <= entity.date_modified) {
await sql.doInTransaction(async () => {
await sql.replace("notes_image", entity);
await sync_table.addNoteImageSync(entity.note_image_id, sourceId);
});
log.info("Update/sync note image " + entity.note_image_id);
}
}
module.exports = {
updateNote,
updateNoteTree,
updateNoteHistory,
updateNoteReordering,
updateOptions,
updateRecentNotes
updateRecentNotes,
updateImage,
updateNoteImage
};

View File

@@ -15,6 +15,14 @@ function newNoteHistoryId() {
return randomString(12);
}
function newImageId() {
return randomString(12);
}
function newNoteImageId() {
return randomString(12);
}
function randomString(length) {
return randtoken.generate(length);
}
@@ -79,6 +87,14 @@ function sanitizeSql(str) {
return str.replace(/'/g, "\\'");
}
function assertArguments() {
for (const i in arguments) {
if (!arguments[i]) {
throw new Error(`Argument idx#${i} should not be falsy: ${arguments[i]}`);
}
}
}
module.exports = {
randomSecureToken,
randomString,
@@ -88,6 +104,8 @@ module.exports = {
newNoteId,
newNoteTreeId,
newNoteHistoryId,
newImageId,
newNoteImageId,
toBase64,
fromBase64,
hmac,
@@ -95,5 +113,6 @@ module.exports = {
hash,
isEmptyOrWhitespace,
getDateTimeForFile,
sanitizeSql
sanitizeSql,
assertArguments
};