267 lines
7.1 KiB
JavaScript
267 lines
7.1 KiB
JavaScript
import { Index } from 'flexsearch'
|
|
import crypto from 'crypto'
|
|
import sodium from 'libsodium-wrappers'
|
|
|
|
export default class NotesAPI {
|
|
constructor(adapter, encryptionKey = null) {
|
|
if (!adapter) {
|
|
throw new Error('NotesAPI requires a storage adapter')
|
|
}
|
|
|
|
this.adapter = adapter
|
|
this.notesCache = new Map()
|
|
this.encryptionKey = encryptionKey || process.env.NOTES_ENCRYPTION_KEY
|
|
this._sodiumReady = false
|
|
|
|
this.index = new Index({
|
|
tokenize: 'forward',
|
|
})
|
|
}
|
|
|
|
async _initSodium() {
|
|
if (!this._sodiumReady) {
|
|
await sodium.ready
|
|
this._sodiumReady = true
|
|
}
|
|
}
|
|
|
|
_encrypt(note) {
|
|
if (!this.encryptionKey) {
|
|
throw new Error('Encryption key not set')
|
|
}
|
|
|
|
const key = Buffer.from(this.encryptionKey, 'hex')
|
|
if (key.length !== 32) {
|
|
throw new Error(
|
|
'Encryption key must be 64 hex characters (32 bytes)',
|
|
)
|
|
}
|
|
|
|
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
|
|
const message = JSON.stringify(note)
|
|
|
|
const ciphertext = sodium.crypto_secretbox_easy(
|
|
Buffer.from(message),
|
|
nonce,
|
|
key,
|
|
)
|
|
|
|
const combined = Buffer.concat([nonce, ciphertext])
|
|
return combined.toString('base64')
|
|
}
|
|
|
|
_decrypt(encryptedData) {
|
|
if (!this.encryptionKey) {
|
|
throw new Error('Encryption key not set')
|
|
}
|
|
|
|
const key = Buffer.from(this.encryptionKey, 'hex')
|
|
if (key.length !== 32) {
|
|
throw new Error(
|
|
'Encryption key must be 64 hex characters (32 bytes)',
|
|
)
|
|
}
|
|
|
|
let combined
|
|
try {
|
|
combined = Buffer.from(encryptedData, 'base64')
|
|
} catch (e) {
|
|
throw new Error('Invalid encrypted data: not valid base64')
|
|
}
|
|
|
|
if (
|
|
combined.length <
|
|
sodium.crypto_secretbox_NONCEBYTES +
|
|
sodium.crypto_secretbox_MACBYTES
|
|
) {
|
|
throw new Error('Invalid encrypted data: too short')
|
|
}
|
|
|
|
const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES)
|
|
const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES)
|
|
|
|
let decrypted
|
|
try {
|
|
decrypted = sodium.crypto_secretbox_open_easy(
|
|
ciphertext,
|
|
nonce,
|
|
key,
|
|
)
|
|
} catch (e) {
|
|
throw new Error('Decryption failed: wrong key or corrupted data')
|
|
}
|
|
|
|
if (!decrypted) {
|
|
throw new Error('Decryption failed: no data returned')
|
|
}
|
|
|
|
const decryptedStr = Buffer.from(decrypted).toString('utf8')
|
|
|
|
try {
|
|
return JSON.parse(decryptedStr)
|
|
} catch (e) {
|
|
throw new Error(
|
|
`Decryption succeeded but invalid JSON: ${decryptedStr}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
async init() {
|
|
await this._initSodium()
|
|
await this.adapter.init()
|
|
|
|
const encryptedNotes = await this.adapter.getAll()
|
|
|
|
for (const encryptedNote of encryptedNotes) {
|
|
try {
|
|
const note = this._decrypt(encryptedNote.data || encryptedNote)
|
|
|
|
this.notesCache.set(note.id, note)
|
|
const searchText =
|
|
note.plainText || this._extractPlainText(note.content)
|
|
this.index.add(note.id, note.title + '\n' + searchText)
|
|
} catch (error) {
|
|
console.error('Failed to decrypt note:', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
_extractPlainText(content) {
|
|
if (!content) return ''
|
|
if (typeof content === 'string') return content
|
|
|
|
const extractText = (node) => {
|
|
if (typeof node === 'string') return node
|
|
if (!node || !node.content) return ''
|
|
return node.content.map(extractText).join(' ')
|
|
}
|
|
|
|
return extractText(content)
|
|
}
|
|
|
|
/* -----------------------
|
|
Public API
|
|
------------------------*/
|
|
getCategories() {
|
|
const categories = new Set()
|
|
|
|
for (const note of this.notesCache.values()) {
|
|
if (note.category) {
|
|
categories.add(note.category)
|
|
}
|
|
}
|
|
|
|
return Array.from(categories).sort()
|
|
}
|
|
|
|
getCategoryNotes(categoryName) {
|
|
return Array.from(this.notesCache.values())
|
|
.filter((n) => n.category === categoryName)
|
|
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
|
|
}
|
|
|
|
getNote(id) {
|
|
return this.notesCache.get(id) ?? null
|
|
}
|
|
|
|
async createNote(metadata = {}, content = '', plainText = '') {
|
|
const id = crypto.randomUUID()
|
|
const now = new Date().toISOString()
|
|
|
|
const note = {
|
|
id,
|
|
title: metadata.title || 'Untitled',
|
|
category: metadata.category || null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
content,
|
|
plainText,
|
|
}
|
|
|
|
const encryptedNote = {
|
|
id: note.id,
|
|
data: this._encrypt(note),
|
|
}
|
|
|
|
this.notesCache.set(id, note)
|
|
this.index.add(id, note.title + '\n' + plainText)
|
|
|
|
await this.adapter.create(encryptedNote)
|
|
|
|
return note
|
|
}
|
|
|
|
async deleteNote(id) {
|
|
await this.adapter.delete(id)
|
|
|
|
this.notesCache.delete(id)
|
|
this.index.remove(id)
|
|
}
|
|
|
|
async updateNote(id, content, plainText = '') {
|
|
const note = this.notesCache.get(id)
|
|
if (!note) throw new Error('Note not found')
|
|
|
|
note.content = content
|
|
note.plainText = plainText
|
|
note.updatedAt = new Date().toISOString()
|
|
|
|
const encryptedNote = {
|
|
id: note.id,
|
|
data: this._encrypt(note),
|
|
}
|
|
|
|
this.index.update(id, note.title + '\n' + plainText)
|
|
|
|
await this.adapter.update(encryptedNote)
|
|
|
|
return note
|
|
}
|
|
|
|
async updateNoteMetadata(id, updates = {}) {
|
|
const note = this.notesCache.get(id)
|
|
if (!note) throw new Error('Note not found')
|
|
|
|
const allowedFields = ['title', 'category']
|
|
for (const key of Object.keys(updates)) {
|
|
if (!allowedFields.includes(key)) {
|
|
throw new Error(`Invalid metadata field: ${key}`)
|
|
}
|
|
}
|
|
|
|
if (updates.title !== undefined) {
|
|
note.title = updates.title
|
|
}
|
|
|
|
if (updates.category !== undefined) {
|
|
note.category = updates.category
|
|
}
|
|
|
|
note.updatedAt = new Date().toISOString()
|
|
|
|
const encryptedNote = {
|
|
id: note.id,
|
|
data: this._encrypt(note),
|
|
}
|
|
|
|
this.index.update(
|
|
id,
|
|
note.title +
|
|
'\n' +
|
|
(note.plainText || this._extractPlainText(note.content)),
|
|
)
|
|
|
|
await this.adapter.update(encryptedNote)
|
|
|
|
return note
|
|
}
|
|
|
|
search(query) {
|
|
const ids = this.index.search(query, {
|
|
limit: 50,
|
|
suggest: true,
|
|
})
|
|
return ids.map((id) => this.notesCache.get(id))
|
|
}
|
|
}
|