b0eee636b5
* Cleaned up unused things * Updated squire which had a comment typo update...thats it * Background color picker has matching colors and styles to text color picker * Added new black theme * Moved search to main page, show it on mobile and added options to push things to notes from search with experimental tag searching * Added active note menu buttons based on cursor location in text * Added more instant updating if app is open in two locations for the same user Scratch Pad and home page update with new notes and new text in real time
1076 lines
30 KiB
JavaScript
1076 lines
30 KiB
JavaScript
let db = require('@config/database')
|
|
|
|
let Note = module.exports = {}
|
|
|
|
let Tags = require('@models/Tag')
|
|
let Attachment = require('@models/Attachment')
|
|
let ShareNote = require('@models/ShareNote')
|
|
|
|
let ProcessText = require('@helpers/ProcessText')
|
|
|
|
const DiffMatchPatch = require('@helpers/DiffMatchPatch')
|
|
|
|
const crypto = require('crypto')
|
|
const cs = require('@helpers/CryptoString')
|
|
const rp = require('request-promise');
|
|
const fs = require('fs')
|
|
|
|
|
|
|
|
const gm = require('gm')
|
|
|
|
Note.test = (userId, masterKey) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
|
let testNoteId = 0
|
|
|
|
Note.create(null, userId, '','', masterKey)
|
|
.then(newNoteId => {
|
|
|
|
console.log('Test: Create Note - Pass')
|
|
testNoteId = newNoteId
|
|
|
|
return Note.update
|
|
(null, userId, testNoteId, 'Note text', 'Test Note beans Title', 0, 0, 0, 'hash', masterKey)
|
|
|
|
})
|
|
.then(() => {
|
|
|
|
console.log('Test: Update Note - Pass')
|
|
|
|
return Note.get(userId, testNoteId, masterKey)
|
|
|
|
})
|
|
.then(updatedText => {
|
|
|
|
console.log('Test: Open Updated Note - Pass')
|
|
|
|
const shareUserId = 61
|
|
return ShareNote.migrateNoteToShared(userId, testNoteId, shareUserId, masterKey)
|
|
})
|
|
.then(shareResults => {
|
|
|
|
console.log('Test: Set Note To Shared - Pass')
|
|
|
|
return Note.get(userId, testNoteId, masterKey)
|
|
|
|
})
|
|
.then(() => {
|
|
|
|
console.log('Test: Open Shared Note - Pass')
|
|
|
|
return Note.update
|
|
(null, userId, testNoteId, 'Shared Update', 'Test Note beans Title', 0, 0, 0, 'hash', masterKey)
|
|
})
|
|
.then(() => {
|
|
|
|
console.log('Test: Update Shared Note - Pass')
|
|
|
|
return Note.reindex(userId, masterKey)
|
|
})
|
|
.then( reindexResults => {
|
|
|
|
console.log(`Test: Reindex Notes - ${reindexResults?'Pass':'Fail'}`)
|
|
|
|
return Note.encryptedIndexSearch(userId, 'beans', null, masterKey)
|
|
|
|
})
|
|
.then(textSearchResults => {
|
|
|
|
if(textSearchResults['ids'] && textSearchResults['ids'].length >= 1){
|
|
console.log('Test: Search Index - Pass')
|
|
} else { console.log('Test: Search Index - Fail') }
|
|
|
|
return Note.delete(userId, testNoteId)
|
|
|
|
})
|
|
.then(results => {
|
|
|
|
console.log('Test: Delete Note - Pass')
|
|
|
|
return resolve('Test: Complete')
|
|
|
|
})
|
|
})
|
|
}
|
|
|
|
//User doesn't have an encrypted note set. Encrypt all notes
|
|
Note.encryptEveryNote = (userId, masterKey) => {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
//Select all the user notes
|
|
db.promise().query(`
|
|
SELECT * FROM note
|
|
JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id)
|
|
WHERE salt IS NULL AND user_id = ? AND encrypted = 0 AND shared = 0`, [userId])
|
|
.then((rows, fields) => {
|
|
|
|
let foundNotes = rows[0]
|
|
console.log('Encrypting user notes ',rows[0].length)
|
|
|
|
// return resolve(true)
|
|
|
|
let allTheUpdates = []
|
|
let timeoutAdder = 0
|
|
foundNotes.forEach(note => {
|
|
timeoutAdder += 100
|
|
const newUpdate = new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
console.log('Encrypting Note ', note.id)
|
|
|
|
const created = Math.round((+new Date)/1000)
|
|
const salt = cs.createSmallSalt()
|
|
|
|
const noteText = note.text
|
|
const noteTitle = note.title
|
|
|
|
const snippet = JSON.stringify([noteTitle, noteText.substring(0, 500)])
|
|
const noteSnippet = cs.encrypt(masterKey, salt, snippet)
|
|
|
|
const textObject = JSON.stringify([noteTitle, noteText])
|
|
const encryptedText = cs.encrypt(masterKey, salt, textObject)
|
|
|
|
db.promise()
|
|
.query('UPDATE note_raw_text SET title = ?, text = ?, snippet = ?, salt = ? WHERE id = ?',
|
|
[null, encryptedText, noteSnippet, salt, note.note_raw_text_id])
|
|
.then(() => {
|
|
resolve(true)
|
|
})
|
|
}, timeoutAdder)
|
|
})
|
|
allTheUpdates.push(newUpdate)
|
|
})
|
|
|
|
Promise.all(allTheUpdates).then(done => {
|
|
|
|
console.log('Indexing first 100')
|
|
return Note.reindex(userId, masterKey)
|
|
|
|
}).then(results => {
|
|
|
|
console.log('Done')
|
|
resolve(true)
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
})
|
|
}
|
|
|
|
//Returns insertedId of new note
|
|
Note.create = (io, userId, noteTitle = '', noteText = '', masterKey) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
|
if(userId == null || userId < 10){ reject('User Id required to create note') }
|
|
|
|
const created = Math.round((+new Date)/1000)
|
|
const salt = cs.createSmallSalt()
|
|
const snippetSalt = cs.createSmallSalt()
|
|
|
|
const snippetObj = JSON.stringify([noteTitle, noteText.substring(0, 500)])
|
|
const snippet = cs.encrypt(masterKey, snippetSalt, snippetObj)
|
|
|
|
const textObject = JSON.stringify([noteTitle, noteText])
|
|
const encryptedText = cs.encrypt(masterKey, salt, textObject)
|
|
|
|
db.promise()
|
|
.query(`INSERT INTO note_raw_text (text, salt, updated) VALUE (?, ?, ?)`, [encryptedText, salt, created])
|
|
.then( (rows, fields) => {
|
|
|
|
const rawTextId = rows[0].insertId
|
|
|
|
return db.promise()
|
|
.query('INSERT INTO note (user_id, note_raw_text_id, created, quick_note, snippet, snippet_salt) VALUES (?,?,?,?,?,?)',
|
|
[userId, rawTextId, created, 0, snippet, snippetSalt])
|
|
})
|
|
.then((rows, fields) => {
|
|
|
|
if(io){
|
|
io.to(userId).emit('new_note_created', rows[0].insertId)
|
|
}
|
|
|
|
// Indexing is done on save
|
|
resolve(rows[0].insertId) //Only return the new note ID when creating a new note
|
|
})
|
|
.catch(console.log)
|
|
})
|
|
}
|
|
|
|
// Called when a note is close
|
|
// Will attempt to reindex all notes that are flagged in database as not indexed
|
|
// Limit to 100 notes per batch
|
|
Note.reindex = (userId, masterKey) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
|
if(!masterKey || masterKey.length == 0){
|
|
return reject('Master key needed for reindex')
|
|
}
|
|
|
|
let notIndexedNoteIds = []
|
|
let searchIndex = null
|
|
let searchIndexSalt = null
|
|
let foundNotes = null
|
|
|
|
//First check if we have any notes to index
|
|
db.promise().query(`
|
|
SELECT note.id, text, salt FROM note
|
|
JOIN note_raw_text ON note.note_raw_text_id = note_raw_text.id
|
|
WHERE indexed = 0 AND encrypted = 0 AND salt IS NOT NULL
|
|
AND user_id = ? LIMIT 100`, [userId])
|
|
.then((rows, fields) => {
|
|
|
|
//Halt execution if there are no new notes
|
|
foundNotes = rows[0]
|
|
if(foundNotes.length == 0){
|
|
throw new Error('No new notes to index')
|
|
}
|
|
|
|
//Select search index, if it doesn't exist, create it
|
|
return db.promise().query(`SELECT * FROM user_encrypted_search_index WHERE user_id = ? LIMIT 1`, [userId])
|
|
|
|
})
|
|
.then((rows, fields) => {
|
|
|
|
if(rows[0].length == 0){
|
|
|
|
//Create search index entry, return an object
|
|
searchIndexSalt = cs.createSmallSalt()
|
|
|
|
//Select all user notes to recreate index
|
|
return db.promise().query(`
|
|
SELECT note.id, text, salt FROM note
|
|
JOIN note_raw_text ON note.note_raw_text_id = note_raw_text.id
|
|
WHERE encrypted = 0 AND user_id = ?`, [userId])
|
|
.then((rows, fields) => {
|
|
|
|
foundNotes = rows[0]
|
|
return db.promise().query("INSERT INTO user_encrypted_search_index (`user_id`, `salt`) VALUES (?,?)", [userId, searchIndexSalt])
|
|
})
|
|
.then((rows, fields) => {
|
|
//return a fresh search index
|
|
return new Promise((resolve, reject) => { resolve('{}') })
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
const row = rows[0][0]
|
|
searchIndexSalt = row.salt
|
|
|
|
//Decrypt search index and continue.
|
|
let decipheredSearchIndex = '{}'
|
|
if(row.index && row.index.length > 0){
|
|
//Decrypt json, do not parse json yet, we want raw text
|
|
decipheredSearchIndex = cs.decrypt(masterKey, searchIndexSalt, row.index)
|
|
}
|
|
|
|
return new Promise((resolve, reject) => { resolve( decipheredSearchIndex ) })
|
|
}
|
|
|
|
})
|
|
.then(rawSearchIndex => {
|
|
|
|
searchIndex = rawSearchIndex
|
|
|
|
//Remove all instances of IDs from text
|
|
foundNotes.forEach(note => {
|
|
|
|
notIndexedNoteIds.push(note.id)
|
|
|
|
//Remove every instance of note id
|
|
const removeId = new RegExp(note.id,"gm")
|
|
const removeDoubles = new RegExp(',,',"g")
|
|
// const removeTrail = new RegExp(',]',"g")
|
|
|
|
|
|
searchIndex = searchIndex
|
|
.replace(removeId, '')
|
|
.replace(removeDoubles, ',')
|
|
.replace(/,]/g, ']')
|
|
.replace(/\[\,/g, '[') //search [,
|
|
})
|
|
|
|
searchIndex = JSON.parse(searchIndex)
|
|
|
|
//Remove unused words, this may not be needed and it increases overhead
|
|
Object.keys(searchIndex).forEach(word => {
|
|
if(searchIndex[word].length == 0){
|
|
delete searchIndex[word]
|
|
}
|
|
})
|
|
|
|
//Process text of each note and add it to the index
|
|
let reindexQueue = []
|
|
let reindexTimer = 0
|
|
foundNotes.forEach(note => {
|
|
|
|
reindexTimer += 50
|
|
let reindexPromise = new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
|
|
if(masterKey == null || note.salt == null){
|
|
console.log('Error indexing note', note.id)
|
|
return resolve(true)
|
|
}
|
|
|
|
const noteHtml = cs.decrypt(masterKey, note.salt, note.text)
|
|
|
|
const rawText =
|
|
ProcessText.removeHtml(noteHtml) //Remove HTML
|
|
.toLowerCase()
|
|
.replace(/style=".*?"/g,'') //Remove inline styles
|
|
.replace (/&#{0,1}[a-z0-9]+;/ig, '') //remove HTML entities
|
|
.replace(/[^A-Za-z0-9]/g, ' ') //Convert all to a-z only
|
|
.replace(/ +(?= )/g,'') //Remove double spaces
|
|
|
|
rawText.split(' ').forEach(word => {
|
|
|
|
//Skip small words
|
|
if(word.length <= 2){ return }
|
|
|
|
if(Array.isArray( searchIndex[word] )){
|
|
if(searchIndex[word].indexOf( note.id ) == -1){
|
|
searchIndex[word].push( note.id )
|
|
}
|
|
} else {
|
|
searchIndex[word] = [ note.id ]
|
|
}
|
|
})
|
|
|
|
return resolve(true)
|
|
|
|
}, reindexTimer)
|
|
})
|
|
|
|
reindexQueue.push(reindexPromise)
|
|
|
|
})
|
|
|
|
return Promise.all(reindexQueue)
|
|
})
|
|
.then(rawSearchIndex => {
|
|
|
|
// console.log('All notes indexed')
|
|
|
|
const created = Math.round((+new Date)/1000)
|
|
const jsonSearchIndex = JSON.stringify(searchIndex)
|
|
const encryptedJsonIndex = cs.encrypt(masterKey, searchIndexSalt, jsonSearchIndex)
|
|
|
|
return db.promise().query("UPDATE user_encrypted_search_index SET `index` = ?, `last_update` = ? WHERE (`user_id` = ?) LIMIT 1",
|
|
[encryptedJsonIndex, created, userId])
|
|
.then((rows, fields) => {
|
|
|
|
return db.promise().query('UPDATE note SET `indexed` = 1 WHERE (`id` IN (?))', [notIndexedNoteIds])
|
|
|
|
})
|
|
.then((rows, fields) => {
|
|
|
|
// console.log('Indexd Note Count: ' + rows[0]['affectedRows'])
|
|
resolve(true)
|
|
|
|
})
|
|
|
|
}).catch(error => {
|
|
console.log('Reindex Error')
|
|
console.log(error)
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
//Find all note Ids that need to be reindexed
|
|
|
|
// return resolve(true)
|
|
return
|
|
|
|
Note.get(userId, noteId)
|
|
.then(note => {
|
|
|
|
let noteText = note.text
|
|
if(note.encrypted == 1){
|
|
noteText = '' //Don't put note text in encrypted notes
|
|
}
|
|
|
|
|
|
//
|
|
// Update Solr index
|
|
//
|
|
Tags.string(userId, noteId)
|
|
.then(tagString => {
|
|
|
|
const fullText = note.title + ' ' + ProcessText.removeHtml(noteText) +' '+ tagString
|
|
|
|
db.promise()
|
|
.query(`
|
|
|
|
INSERT INTO note_text_index (note_id, user_id, text)
|
|
VALUES (?,?,?)
|
|
ON DUPLICATE KEY UPDATE text = ?
|
|
|
|
`, [noteId, userId, fullText, fullText])
|
|
.then((rows, fields) => {
|
|
resolve(true)
|
|
})
|
|
.catch(console.log)
|
|
|
|
})
|
|
|
|
})
|
|
})
|
|
}
|
|
|
|
// Returns updated note text
|
|
Note.update = (io, userId, noteId, noteText, noteTitle, color, pinned, archived, hash, masterKey) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
|
const now = Math.round((+new Date)/1000)
|
|
|
|
let noteSnippet = ''
|
|
|
|
let User = require('@models/User')
|
|
let userPrivateKey = null
|
|
User.getPrivateKey(userId, masterKey)
|
|
.then(privateKey => {
|
|
|
|
userPrivateKey = privateKey
|
|
|
|
return db.promise()
|
|
.query(`
|
|
SELECT note_raw_text_id, salt, snippet_salt, encrypted_share_password_key FROM note
|
|
JOIN note_raw_text ON note_raw_text_id = note_raw_text.id
|
|
WHERE note.id = ? AND user_id = ?`, [noteId, userId])
|
|
|
|
})
|
|
.then((rows, fields) => {
|
|
|
|
const textId = rows[0][0]['note_raw_text_id']
|
|
let salt = rows[0][0]['salt']
|
|
let snippetSalt = rows[0][0]['snippet_salt']
|
|
|
|
//Shared notes use encrypted key - decrypt key then decrypt note
|
|
const encryptedShareKey = rows[0][0].encrypted_share_password_key
|
|
if(encryptedShareKey != null){
|
|
masterKey = crypto.privateDecrypt(userPrivateKey,
|
|
Buffer.from(encryptedShareKey, 'base64') )
|
|
}
|
|
|
|
let encryptedNoteText = ''
|
|
//Create encrypted snippet
|
|
const snippet = JSON.stringify([noteTitle, noteText.substring(0, 500)])
|
|
noteSnippet = cs.encrypt(masterKey, snippetSalt, snippet)
|
|
|
|
//Encrypt note text
|
|
const textObject = JSON.stringify([noteTitle, noteText])
|
|
encryptedNoteText = cs.encrypt(masterKey, salt, textObject)
|
|
|
|
//
|
|
// @TODO - this needs some kind of rate limiting
|
|
// A note shared with a lot of users could do a ton of updates every save
|
|
//
|
|
//Update text snippet for all other shared users
|
|
db.promise().query('SELECT * FROM note WHERE note_raw_text_id = ? AND id != ?', [textId, noteId])
|
|
.then((rows, fields) => {
|
|
for (var i = 0; i < rows[0].length; i++) {
|
|
const otherNote = rows[0][i]
|
|
//Re-encrypt for other user
|
|
const updatedSnippet = cs.encrypt(masterKey, otherNote.snippet_salt, snippet)
|
|
db.promise().query('UPDATE note SET snippet = ? WHERE id = ?', [updatedSnippet, otherNote.id])
|
|
}
|
|
})
|
|
|
|
|
|
//Update Note text
|
|
return db.promise()
|
|
.query('UPDATE note_raw_text SET text = ?, updated = ? WHERE id = ?', [encryptedNoteText, now, textId])
|
|
})
|
|
.then( (rows, fields) => {
|
|
|
|
//Update other note attributes
|
|
return db.promise()
|
|
.query('UPDATE note SET pinned = ?, archived = ?, color = ?, snippet = ?, indexed = 0 WHERE id = ? AND user_id = ? LIMIT 1',
|
|
[pinned, archived, color, noteSnippet, noteId, userId])
|
|
|
|
})
|
|
.then((rows, fields) => {
|
|
|
|
if(io){
|
|
io.to(userId).emit('new_note_text_saved', {noteId, hash})
|
|
}
|
|
|
|
//Async attachment reindex
|
|
Attachment.scanTextForWebsites(io, userId, noteId, noteText)
|
|
|
|
//Send back updated response
|
|
resolve(rows[0])
|
|
})
|
|
.catch(console.log)
|
|
})
|
|
}
|
|
|
|
Note.setPinned = (userId, noteId, pinnedBoolean) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
|
const pinned = pinnedBoolean ? 1:0
|
|
|
|
//Update other note attributes
|
|
return db.promise()
|
|
.query('UPDATE note SET pinned = ? WHERE id = ? AND user_id = ? LIMIT 1',
|
|
[pinned, noteId, userId])
|
|
.then((rows, fields) => {
|
|
resolve(true)
|
|
})
|
|
})
|
|
}
|
|
|
|
Note.setArchived = (userId, noteId, archivedBoolead) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
|
const archived = archivedBoolead ? 1:0
|
|
|
|
//Update other note attributes
|
|
return db.promise()
|
|
.query('UPDATE note SET archived = ? WHERE id = ? AND user_id = ? LIMIT 1',
|
|
[archived, noteId, userId])
|
|
.then((rows, fields) => {
|
|
resolve(true)
|
|
})
|
|
})
|
|
}
|
|
|
|
//
|
|
// Delete a note and all its remaining parts
|
|
//
|
|
Note.delete = (userId, noteId) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
|
//
|
|
// Delete, note, text, search index and associated tags
|
|
// Leave the attachments, they can be deleted on their own
|
|
// Leave Tags, their text is shared
|
|
|
|
let rawTextId = null
|
|
let noteTextCount = 0
|
|
|
|
// Lookup the note text ID, we need this to count usages
|
|
db.promise()
|
|
.query('SELECT note_raw_text_id FROM note WHERE id = ? AND user_id = ?', [noteId, userId])
|
|
.then((rows, fields) => {
|
|
|
|
//Save the raw text ID
|
|
rawTextId = rows[0][0]['note_raw_text_id']
|
|
|
|
return db.promise()
|
|
.query('SELECT count(id) as count FROM note WHERE note_raw_text_id = ?', [rawTextId])
|
|
})
|
|
.then((rows, fields) => {
|
|
|
|
//Save the number of times the note is used
|
|
noteTextCount = rows[0][0]['count']
|
|
|
|
//Don't delete text if its shared
|
|
if(noteTextCount == 1){
|
|
//If text is only used on one note, delete it (its not shared)
|
|
return db.promise()
|
|
.query('SELECT count(id) as count FROM note WHERE note_raw_text_id = ?', [rawTextId])
|
|
} else {
|
|
return new Promise((resolve, reject) => {
|
|
resolve(true)
|
|
})
|
|
}
|
|
})
|
|
.then( results => {
|
|
// Delete Note entry for this user.
|
|
return db.promise()
|
|
.query('DELETE FROM note WHERE note.id = ? AND note.user_id = ?', [noteId, userId])
|
|
})
|
|
.then((rows, fields) => {
|
|
// Delete search index
|
|
return db.promise()
|
|
.query('DELETE FROM note_text_index WHERE note_text_index.note_id = ? AND note_text_index.user_id = ?', [noteId,userId])
|
|
})
|
|
.then((rows, fields) => {
|
|
// delete tags
|
|
return db.promise()
|
|
.query('DELETE FROM note_tag WHERE note_tag.note_id = ? AND note_tag.user_id = ?', [noteId,userId])
|
|
})
|
|
.then((rows, fields) => {
|
|
|
|
//IF there are nots with a matching raw text id, we want to under their share status
|
|
db.promise().query('SELECT id FROM note WHERE note_raw_text_id = ?',[rawTextId])
|
|
.then((rows, fields) => {
|
|
if(rows[0].length == 1){
|
|
db.promise().query('UPDATE note SET shared = 0 WHERE id = ?', [rows[0][0]['id']])
|
|
}
|
|
})
|
|
|
|
resolve(true)
|
|
})
|
|
})
|
|
}
|
|
|
|
//text is the current text for the note that will be compared to the text in the database
|
|
Note.getDiffText = (userId, noteId, usersCurrentText, lastUpdated) => {
|
|
return new Promise((resolve, reject) => {
|
|
Note.get(userId, noteId)
|
|
.then(noteObject => {
|
|
|
|
if(!noteObject.text || !usersCurrentText || noteObject.encrypted == 1){
|
|
return resolve(null)
|
|
}
|
|
|
|
let oldText = noteObject.text.replace(/(\r\n|\n|\r)/gm,"")
|
|
let newText = usersCurrentText.replace(/(\r\n|\n|\r)/gm,"")
|
|
|
|
if(noteObject.updated == lastUpdated){
|
|
// console.log('No note diff')
|
|
return resolve(null)
|
|
}
|
|
|
|
if(noteObject.updated > lastUpdated){
|
|
newText = noteObject.text.replace(/(\r\n|\n|\r)/gm,"")
|
|
oldText = usersCurrentText.replace(/(\r\n|\n|\r)/gm,"")
|
|
}
|
|
|
|
const dmp = new DiffMatchPatch.diff_match_patch()
|
|
const diff = dmp.diff_main(oldText, newText)
|
|
|
|
dmp.diff_cleanupSemantic(diff)
|
|
const patch_list = dmp.patch_make(oldText, newText, diff);
|
|
const patch_text = dmp.patch_toText(patch_list);
|
|
|
|
//Patch text - shows a list of changes
|
|
var patches = dmp.patch_fromText(patch_text);
|
|
// console.log(patch_text)
|
|
|
|
//results[1] - contains diagnostic data for patch apply, its possible it can fail
|
|
var results = dmp.patch_apply(patches, oldText);
|
|
|
|
//Compile return data for front end
|
|
const returnData = {
|
|
updatedText: results[0],
|
|
diffs: results[1].length, //Only use length for now
|
|
updated: Math.max(noteObject.updated,lastUpdated) //Return most recently updated date
|
|
|
|
}
|
|
|
|
//Final change in notes
|
|
// console.log(returnData)
|
|
|
|
resolve(returnData)
|
|
})
|
|
})
|
|
|
|
}
|
|
|
|
Note.get = (userId, noteId, masterKey) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
|
let User = require('@models/User')
|
|
|
|
if(!masterKey || masterKey.length == 0){
|
|
return reject('Get note called without master key')
|
|
}
|
|
|
|
let userPrivateKey = null;
|
|
|
|
User.getPrivateKey(userId, masterKey)
|
|
.then(privateKey => {
|
|
|
|
//Grab users private key
|
|
userPrivateKey = privateKey
|
|
|
|
return db.promise()
|
|
.query(`
|
|
SELECT
|
|
note_raw_text.text,
|
|
note_raw_text.salt,
|
|
note_raw_text.updated as updated,
|
|
note.id,
|
|
note.user_id,
|
|
note.created,
|
|
note.pinned,
|
|
note.archived,
|
|
note.color,
|
|
note.encrypted_share_password_key,
|
|
count(distinct attachment.id) as attachment_count,
|
|
note.note_raw_text_id as rawTextId,
|
|
shareUser.username as shareUsername
|
|
FROM note
|
|
JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id)
|
|
LEFT JOIN attachment ON (note.id = attachment.note_id)
|
|
LEFT JOIN user as shareUser ON (note.share_user_id = shareUser.id)
|
|
WHERE note.user_id = ? AND note.id = ? LIMIT 1`, [userId, noteId])
|
|
|
|
})
|
|
.then((rows, fields) => {
|
|
|
|
const nowTime = Math.round((+new Date)/1000)
|
|
let noteLockedOut = false
|
|
let noteData = rows[0][0]
|
|
// const rawTextId = noteData['rawTextId']
|
|
|
|
//Block access to notes if invalid or user doesn't have access
|
|
if(!noteData || !noteData['user_id'] || noteData['user_id'] != userId || noteData['id'] != noteId){
|
|
return resolve(false)
|
|
}
|
|
|
|
//Shared notes use encrypted key - decrypt key then decrypt note
|
|
if(noteData.encrypted_share_password_key != null){
|
|
masterKey = crypto.privateDecrypt(userPrivateKey,
|
|
Buffer.from(noteData.encrypted_share_password_key, 'base64') )
|
|
}
|
|
|
|
//Normal Encrypted note
|
|
const decipheredText = cs.decrypt(masterKey, noteData.salt, noteData.text)
|
|
if(decipheredText == null){
|
|
throw new Error('Unable to decropt note text')
|
|
}
|
|
//Parse title and text from encrypted data and update object
|
|
const textObject = JSON.parse(decipheredText)
|
|
noteData.title = textObject[0]
|
|
noteData.text = textObject[1]
|
|
|
|
db.promise().query(`UPDATE note SET opened = ? WHERE (id = ?)`, [nowTime, noteId])
|
|
|
|
//Return note data
|
|
delete noteData.salt //remove salt from return data
|
|
delete noteData.encrypted_share_password_key
|
|
noteData.lockedOut = noteLockedOut
|
|
resolve(noteData)
|
|
|
|
})
|
|
.catch(error => {
|
|
console.log(error)
|
|
resolve(false)
|
|
})
|
|
})
|
|
}
|
|
|
|
//Public note share action -> may not be used
|
|
Note.getShared = (noteId) => {
|
|
return new Promise((resolve, reject) => {
|
|
db.promise()
|
|
.query('SELECT text, color FROM note WHERE id = ? AND shared = 1 LIMIT 1', [noteId])
|
|
.then((rows, fields) => {
|
|
|
|
//Return note data
|
|
resolve(rows[0][0])
|
|
|
|
})
|
|
.catch(console.log)
|
|
})
|
|
}
|
|
|
|
// Searches text index, returns nothing if there is no search query
|
|
Note.encryptedIndexSearch = (userId, searchQuery, searchTags, masterKey) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
|
if(searchQuery.length == 0){
|
|
resolve(null)
|
|
} else {
|
|
|
|
if(!masterKey || masterKey == null){
|
|
console.log('Attempting to search wiouth key')
|
|
return resolve(null)
|
|
}
|
|
|
|
//Search the search index
|
|
db.promise().query(`SELECT * FROM user_encrypted_search_index WHERE user_id = ? LIMIT 1`, [userId])
|
|
.then((rows, fields) => {
|
|
|
|
if(rows[0].length == 1){
|
|
|
|
//Lookup, decrypt and parse search index
|
|
const row = rows[0][0]
|
|
const decipheredSearchIndex = cs.decrypt(masterKey, row.salt, row.index)
|
|
const searchIndex = JSON.parse(decipheredSearchIndex)
|
|
|
|
//Clean up search word
|
|
const word = searchQuery.toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
|
|
let noteIds = []
|
|
let partials = []
|
|
Object.keys(searchIndex).forEach(wordIndex => {
|
|
if( wordIndex.indexOf(word) != -1 && wordIndex != word){
|
|
partials.push(wordIndex)
|
|
noteIds.push(...searchIndex[wordIndex])
|
|
}
|
|
})
|
|
|
|
const exactArray = searchIndex[word] ? searchIndex[word] : []
|
|
|
|
let searchData = {
|
|
'word':word,
|
|
'exact': exactArray,
|
|
'partials': partials,
|
|
'partial': [...new Set(noteIds) ],
|
|
}
|
|
|
|
//Remove exact matches from partials set if there is overlap
|
|
if(searchData['exact'].length > 0 && searchData['partial'].length > 0){
|
|
searchData['partial'] = searchData['partial']
|
|
.filter( ( el ) => !searchData['exact'].includes( el ) )
|
|
}
|
|
|
|
searchData['ids'] = searchData['exact'].concat(searchData['partial'])
|
|
searchData['total'] = searchData['ids'].length
|
|
|
|
// console.log(searchData['total'])
|
|
|
|
return resolve({ 'ids':searchData['ids'] })
|
|
|
|
|
|
} else {
|
|
return resolve(null)
|
|
}
|
|
|
|
})
|
|
}
|
|
|
|
})
|
|
}
|
|
|
|
Note.search = (userId, searchQuery, searchTags, fastFilters, masterKey) => {
|
|
return new Promise((resolve, reject) => {
|
|
//Define return data objects
|
|
let returnData = {
|
|
'notes':[],
|
|
'total':0,
|
|
}
|
|
|
|
let userPrivateKey = null
|
|
|
|
let User = require('@models/User')
|
|
User.generateKeypair(userId, masterKey)
|
|
.then(({publicKey, privateKey}) => {
|
|
|
|
userPrivateKey = privateKey
|
|
return Note.encryptedIndexSearch(userId, searchQuery, searchTags, masterKey)
|
|
})
|
|
.then( (textSearchResults) => {
|
|
|
|
//Pull out search results from previous query
|
|
let textSearchIds = []
|
|
let returnTagResults = false
|
|
let searchAllNotes = false
|
|
|
|
if(textSearchResults != null){
|
|
textSearchIds = textSearchResults['ids']
|
|
returnData['total'] = textSearchIds.length
|
|
// highlights = textSearchResults['snippets']
|
|
}
|
|
|
|
//No results, return empty data
|
|
if(textSearchIds.length == 0 && searchQuery.length > 0){
|
|
return resolve(returnData)
|
|
}
|
|
|
|
// Base of the query, modified with fastFilters
|
|
// Add to query for character counts -> CHAR_LENGTH(note.text) as chars
|
|
|
|
//SUBSTRING(note_raw_text.text, 1, 500) as text,
|
|
let searchParams = [userId]
|
|
let noteSearchQuery = `
|
|
SELECT note.id,
|
|
note.snippet as snippet,
|
|
note.snippet_salt as salt,
|
|
note_raw_text.updated as updated,
|
|
opened,
|
|
color,
|
|
count(distinct note_tag.id) as tag_count,
|
|
count(distinct attachment.id) as attachment_count,
|
|
note.pinned,
|
|
note.archived,
|
|
note.encrypted,
|
|
GROUP_CONCAT(DISTINCT tag.text) as tags,
|
|
GROUP_CONCAT(DISTINCT attachment.file_location) as thumbs,
|
|
shareUser.username as shareUsername,
|
|
note.shared,
|
|
note.encrypted_share_password_key
|
|
FROM note
|
|
JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id)
|
|
LEFT JOIN note_tag ON (note.id = note_tag.note_id)
|
|
LEFT JOIN tag ON (tag.id = note_tag.tag_id)
|
|
LEFT JOIN attachment ON (note.id = attachment.note_id AND attachment.visible = 1)
|
|
LEFT JOIN user as shareUser ON (note.share_user_id = shareUser.id)
|
|
WHERE note.user_id = ?
|
|
`
|
|
|
|
//Show shared notes
|
|
if(fastFilters.onlyShowSharedNotes == 1){
|
|
//share_user_id means your shared them, a note with a shared user id filled in means it was shared
|
|
noteSearchQuery += ` AND share_user_id IS NOT NULL OR (note.shared = 2 AND note.user_id = ?)`
|
|
searchParams.push(userId)
|
|
//Show notes shared with you
|
|
} else {
|
|
noteSearchQuery += ' AND note.share_user_id IS NULL'
|
|
}
|
|
|
|
//If text search returned results, limit search to those ids
|
|
if(textSearchIds.length > 0){
|
|
searchParams.push(textSearchIds)
|
|
noteSearchQuery += ' AND note.id IN (?)'
|
|
searchAllNotes = true
|
|
}
|
|
|
|
if(fastFilters.noteIdSet && fastFilters.noteIdSet.length > 0){
|
|
searchParams.push(fastFilters.noteIdSet)
|
|
noteSearchQuery += ' AND note.id IN (?)'
|
|
searchAllNotes = true
|
|
}
|
|
|
|
//Encrypted Note
|
|
if(fastFilters.onlyShowEncrypted == 1){
|
|
noteSearchQuery += ' AND encrypted = 1'
|
|
searchAllNotes = true
|
|
}
|
|
|
|
//If tags are passed, use those tags in search
|
|
if(searchTags.length > 0){
|
|
searchParams.push(searchTags)
|
|
noteSearchQuery += ' AND note_tag.tag_id IN (?)'
|
|
}
|
|
|
|
//Show archived notes, only if fast filter is set, default to not archived
|
|
if(searchAllNotes == false){
|
|
if(fastFilters.onlyArchived == 1){
|
|
noteSearchQuery += ' AND note.archived = 1' //Show Archived
|
|
} else {
|
|
noteSearchQuery += ' AND note.archived = 0' //Exclude archived
|
|
}
|
|
}
|
|
|
|
//Finish up note query
|
|
noteSearchQuery += ' GROUP BY note.id'
|
|
|
|
//Only show notes with Tags
|
|
if(fastFilters.withTags == 1){
|
|
returnTagResults = true
|
|
noteSearchQuery += ' HAVING tag_count > 0'
|
|
}
|
|
//Only show notes with links
|
|
if(fastFilters.withLinks == 1){
|
|
returnTagResults = true
|
|
noteSearchQuery += ' HAVING attachment_count > 0'
|
|
}
|
|
//Only show archived notes
|
|
if(fastFilters.onlyArchived == 1){
|
|
returnTagResults = true
|
|
noteSearchQuery += ' HAVING note.archived = 1'
|
|
}
|
|
|
|
//
|
|
// Always prioritize pinned notes in searches.
|
|
|
|
//Default Sort, order by last updated
|
|
let defaultOrderBy = ' ORDER BY note.pinned DESC, updated DESC, note.created DESC, note.opened DESC, id DESC'
|
|
|
|
//Order by Last Created Date
|
|
if(fastFilters.lastCreated == 1){
|
|
defaultOrderBy = ' ORDER BY note.pinned DESC, note.created DESC, updated DESC, note.opened DESC, id DESC'
|
|
}
|
|
//Order by last Opened Date
|
|
if(fastFilters.lastOpened == 1){
|
|
defaultOrderBy = ' ORDER BY note.pinned DESC, opened DESC, updated DESC, note.created DESC, id DESC'
|
|
}
|
|
|
|
//Append Order by to query
|
|
noteSearchQuery += defaultOrderBy
|
|
|
|
|
|
//Manage limit params if set
|
|
if(fastFilters.limitSize > 0 || fastFilters.limitOffset > 0){
|
|
|
|
const limitSize = parseInt(fastFilters.limitSize, 10) || 10 //Use int or default to 10
|
|
const limitOffset = parseInt(fastFilters.limitOffset, 10) || 0 //Either parse int, or use zero
|
|
|
|
|
|
// console.log(` LIMIT ${limitOffset}, ${limitSize}`)
|
|
noteSearchQuery += ` LIMIT ${limitOffset}, ${limitSize}`
|
|
}
|
|
|
|
// console.log('------------- Final Query --------------')
|
|
// console.log(noteSearchQuery)
|
|
// console.log('------------- ----------- --------------')
|
|
|
|
db.promise()
|
|
.query(noteSearchQuery, searchParams)
|
|
.then((noteRows, noteFields) => {
|
|
|
|
//Current note key may change, default to master key
|
|
let currentNoteKey = masterKey
|
|
|
|
//Push all notes
|
|
returnData['notes'] = noteRows[0]
|
|
|
|
//pull out all note ids so we can fetch all tags for those notes
|
|
let noteIds = []
|
|
returnData['notes'].forEach(note => {
|
|
|
|
//Grab note ID for finding tags
|
|
noteIds.push(note.id)
|
|
|
|
|
|
//Shared notes use encrypted key - decrypt key then decrypt note
|
|
const encryptedShareKey = note.encrypted_share_password_key
|
|
if(encryptedShareKey != null){
|
|
currentNoteKey = crypto.privateDecrypt(userPrivateKey,
|
|
Buffer.from(encryptedShareKey, 'base64') )
|
|
}
|
|
|
|
|
|
//Decrypt note text
|
|
if(note.snippet && note.salt){
|
|
const decipheredText = cs.decrypt(currentNoteKey, note.salt, note.snippet)
|
|
const textObject = JSON.parse(decipheredText)
|
|
if(textObject != null && textObject.length == 2){
|
|
note.title = textObject[0]
|
|
note.text = textObject[1]
|
|
}
|
|
}
|
|
|
|
//Deduce note title
|
|
const textData = ProcessText.deduceNoteTitle(note.title, note.text)
|
|
|
|
note.title = textData.title
|
|
note.subtext = textData.sub
|
|
|
|
//Remove these variables
|
|
note.note_highlights = []
|
|
note.attachment_highlights = []
|
|
note.tag_highlights = []
|
|
|
|
//Limit number of attachment thumbs to 4
|
|
if(note.thumbs){
|
|
//Convert comma delimited string to array
|
|
let thumbArray = note.thumbs.split(',').reverse()
|
|
//Limit array to 4 or size of array
|
|
thumbArray.length = Math.min(thumbArray.length, 4)
|
|
note.thumbs = thumbArray
|
|
}
|
|
|
|
//Clear out note.text before sending it to front end, its being used in title and subtext
|
|
delete note.snippet
|
|
delete note.salt
|
|
})
|
|
|
|
|
|
return resolve(returnData)
|
|
|
|
|
|
})
|
|
.catch(console.log)
|
|
|
|
})
|
|
.catch(console.log)
|
|
|
|
})
|
|
} |