added support for trilium-sender

This commit is contained in:
azivner 2018-02-11 00:18:59 -05:00
parent 660908c54b
commit 7b77e40514
15 changed files with 272 additions and 106 deletions

View 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
);

View File

@ -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
);

View File

@ -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;

View File

@ -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
View 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;

View File

@ -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;

View File

@ -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 = {

View File

@ -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,

View File

@ -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
View 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
};

View File

@ -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) {

View File

@ -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}`);
}

View File

@ -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

View File

@ -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
};

View File

@ -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,