Files
Toolbox/src/server/cloud_notes.js
T

401 lines
17 KiB
JavaScript

const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'cloud_notes.db')
const db = new Database(dbPath)
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
html TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
// ensure optional columns
try {
const cols = db.prepare(`PRAGMA table_info(notes)`).all().map(c => c.name)
if (!cols.includes('starred')) db.exec(`ALTER TABLE notes ADD COLUMN starred INTEGER DEFAULT 0`)
if (!cols.includes('pinned')) db.exec(`ALTER TABLE notes ADD COLUMN pinned INTEGER DEFAULT 0`)
if (!cols.includes('deleted_at')) db.exec(`ALTER TABLE notes ADD COLUMN deleted_at TEXT`)
} catch {}
db.exec(`CREATE TABLE IF NOT EXISTS note_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
title TEXT NOT NULL,
html TEXT NOT NULL,
saved_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
color TEXT,
sort_index INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS note_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);`)
const getUserCount = db.prepare(`SELECT COUNT(1) AS c FROM users`)
const findUser = db.prepare(`SELECT * FROM users WHERE username=?`)
const createUser = db.prepare(`INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)`)
const hashPassword = (pwd, saltHex) => {
const salt = Buffer.from(saltHex, 'hex')
const key = crypto.scryptSync(String(pwd || ''), salt, 32)
return key.toString('hex')
}
const ensureAdmin = () => {
const c = getUserCount.get().c
if (c > 0) return
const salt = crypto.randomBytes(16).toString('hex')
const defaultPass = process.env.CLOUD_NOTES_DEFAULT_PASSWORD || process.env.LOCK_DEFAULT_PASSWORD || ''
const hash = hashPassword(defaultPass, salt)
createUser.run('admin_cn', hash, salt)
}
ensureAdmin()
const login = (username, password) => {
const u = findUser.get(username)
if (!u) {
const exists = getUserCount.get().c > 0
if (exists) return null
const salt = crypto.randomBytes(16).toString('hex')
const hash = hashPassword(password, salt)
createUser.run(username, hash, salt)
return findUser.get(username)
}
const ok = u.password_hash === hashPassword(password, u.salt)
return ok ? u : null
}
const insertNote = db.prepare(`INSERT INTO notes (user_id, title, html) VALUES (@user_id, @title, @html)`)
const updateNoteStmt = db.prepare(`UPDATE notes SET title=@title, html=@html, updated_at=datetime('now') WHERE id=@id`)
const insertVersion = db.prepare(`INSERT INTO note_versions (note_id, title, html) VALUES (@note_id, @title, @html)`)
const listVersionsStmt = db.prepare(`SELECT id, note_id, title, html, datetime(saved_at, 'localtime') AS saved_at FROM note_versions WHERE note_id=? ORDER BY id DESC LIMIT 50`)
const latestVersionStmt = db.prepare(`SELECT id, note_id, title, html, datetime(saved_at, 'localtime') AS saved_at FROM note_versions WHERE note_id=? ORDER BY id DESC LIMIT 1`)
const createAttachmentStmt = db.prepare(`INSERT INTO attachments (note_id, filename, file_path, file_size) VALUES (@note_id, @filename, @file_path, @file_size)`)
const listAttachmentsStmt = db.prepare(`SELECT id, note_id, filename, file_path, file_size, datetime(created_at, 'localtime') AS created_at FROM attachments WHERE note_id=? ORDER BY id ASC`)
const getAttachmentByIdStmt = db.prepare(`SELECT id, file_path FROM attachments WHERE id=?`)
const deleteAttachmentStmt = db.prepare(`DELETE FROM attachments WHERE id=?`)
const countAttachmentByPathStmt = db.prepare(`SELECT COUNT(1) AS c FROM attachments WHERE file_path=?`)
const listNotesStmt = db.prepare(`SELECT id, title, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, LENGTH(html) AS size_bytes FROM notes WHERE user_id=? AND deleted_at IS NULL ORDER BY pinned DESC, updated_at DESC`)
const getNoteStmt = db.prepare(`SELECT id, user_id, title, html, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, datetime(deleted_at, 'localtime') AS deleted_at, LENGTH(html) AS size_bytes FROM notes WHERE id=?`)
const searchNotesStmt = db.prepare(`SELECT id, user_id, title, html, datetime(created_at, 'localtime') AS created_at, datetime(updated_at, 'localtime') AS updated_at, starred, pinned, LENGTH(html) AS size_bytes FROM notes WHERE user_id=? AND deleted_at IS NULL AND (title LIKE ? OR html LIKE ?) ORDER BY updated_at DESC`)
const listTrashNotesStmt = db.prepare(`SELECT id, title, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, datetime(deleted_at, 'localtime') AS deleted_at, LENGTH(html) AS size_bytes FROM notes WHERE user_id=? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC, updated_at DESC`)
const softDeleteNoteStmt = db.prepare(`UPDATE notes SET deleted_at=datetime('now') WHERE id=@id`)
const restoreNoteStmt = db.prepare(`UPDATE notes SET deleted_at=NULL WHERE id=@id`)
const hardDeleteNoteStmt = db.prepare(`DELETE FROM notes WHERE id=@id`)
const setStarStmt = db.prepare(`UPDATE notes SET starred=@starred WHERE id=@id`)
const setPinStmt = db.prepare(`UPDATE notes SET pinned=@pinned WHERE id=@id`)
const listTagsStmt = db.prepare(`SELECT id, user_id, name, color, sort_index, datetime(created_at, 'localtime') AS created_at, datetime(updated_at, 'localtime') AS updated_at FROM tags WHERE user_id=? ORDER BY sort_index ASC, id ASC`)
const insertTagStmt = db.prepare(`INSERT INTO tags (user_id, name, color, sort_index) VALUES (@user_id, @name, @color, @sort_index)`)
const updateTagStmt = db.prepare(`UPDATE tags SET name=@name, color=@color, sort_index=@sort_index, updated_at=datetime('now') WHERE id=@id AND user_id=@user_id`)
const deleteTagStmt = db.prepare(`DELETE FROM tags WHERE id=? AND user_id=?`)
const deleteNoteTagsByTagStmt = db.prepare(`DELETE FROM note_tags WHERE tag_id=?`)
const listNoteTagsStmt = db.prepare(`SELECT tag_id FROM note_tags WHERE note_id=?`)
const deleteNoteTagsByNoteStmt = db.prepare(`DELETE FROM note_tags WHERE note_id=?`)
const insertNoteTagStmt = db.prepare(`INSERT INTO note_tags (note_id, tag_id) VALUES (?, ?)`)
const listNotesByTagStmt = db.prepare(`SELECT n.id, n.title, n.starred, n.pinned, datetime(n.updated_at, 'localtime') AS updated_at, datetime(n.created_at, 'localtime') AS created_at, LENGTH(n.html) AS size_bytes FROM notes n JOIN note_tags t ON n.id=t.note_id WHERE n.user_id=? AND n.deleted_at IS NULL AND t.tag_id=? ORDER BY n.pinned DESC, n.updated_at DESC`)
const tagStatsStmt = db.prepare(`SELECT t.id, t.user_id, t.name, t.color, t.sort_index, COUNT(nt.note_id) AS note_count FROM tags t LEFT JOIN note_tags nt ON t.id=nt.tag_id LEFT JOIN notes n ON nt.note_id=n.id AND n.deleted_at IS NULL WHERE t.user_id=? GROUP BY t.id, t.user_id, t.name, t.color, t.sort_index ORDER BY note_count DESC, t.sort_index ASC, t.id ASC`)
const untaggedCountStmt = db.prepare(`SELECT COUNT(1) AS c FROM notes n WHERE n.user_id=? AND n.deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM note_tags nt WHERE nt.note_id=n.id)`)
const recentAttachmentsByUserStmt = db.prepare(`SELECT a.id, a.note_id, a.filename, a.file_path, a.file_size, datetime(a.created_at, 'localtime') AS created_at, n.title AS note_title FROM attachments a JOIN notes n ON a.note_id=n.id WHERE n.user_id=? ORDER BY a.created_at DESC LIMIT 10`)
const getSettingStmt = db.prepare(`SELECT value FROM settings WHERE key=?`)
const upsertSettingStmt = db.prepare(`INSERT INTO settings (key, value) VALUES (@key, @value) ON CONFLICT(key) DO UPDATE SET value=@value, updated_at=datetime('now')`)
const defaultTinyToolbarSettings = () => ({
undo_redo: true,
blocks_fontsize: true,
inline_format: true,
align: true,
indent: true,
lists: true,
table: true,
media: true,
insert_misc: true,
code: true,
page_tools: true,
search_visual: true,
preview_fullscreen_print: true,
cn_tools: true,
menubar: true,
upgrade_button: true
})
const normalizeTinyToolbarSettings = raw => {
const def = defaultTinyToolbarSettings()
const out = {}
Object.keys(def).forEach(k => {
out[k] = raw && Object.prototype.hasOwnProperty.call(raw, k) ? !!raw[k] : def[k]
})
return out
}
const getTinyToolbarSettings = () => {
const row = getSettingStmt.get('tiny_toolbar')
if (!row || typeof row.value !== 'string') return defaultTinyToolbarSettings()
try {
const raw = JSON.parse(row.value || '{}')
return normalizeTinyToolbarSettings(raw)
} catch {
return defaultTinyToolbarSettings()
}
}
const saveTinyToolbarSettings = settings => {
const norm = normalizeTinyToolbarSettings(settings || {})
const value = JSON.stringify(norm)
upsertSettingStmt.run({ key: 'tiny_toolbar', value })
return norm
}
const saveNote = payload => {
const id = parseInt(payload.id || '0', 10)
const user_id = parseInt(payload.user_id || '1', 10)
const title = String(payload.title || '未命名')
const html = String(payload.html || '')
const baseVersionId = parseInt(payload.base_version_id || '0', 10)
const force = !!payload.force
let noteId = 0
let versionId = 0
if (!id) {
const info = insertNote.run({ user_id, title, html })
const vinfo = insertVersion.run({ note_id: info.lastInsertRowid, title, html })
noteId = info.lastInsertRowid
versionId = vinfo.lastInsertRowid
} else {
const latest = latestVersionStmt.get(id)
const latestId = latest && latest.id ? latest.id : 0
if (baseVersionId && latestId && baseVersionId !== latestId && !force) {
return { conflict: true, latest }
}
updateNoteStmt.run({ id, title, html })
const vinfo = insertVersion.run({ note_id: id, title, html })
noteId = id
versionId = vinfo.lastInsertRowid
}
const tagIds = Array.isArray(payload.tag_ids) ? payload.tag_ids.map(x => parseInt(x, 10)).filter(x => x > 0) : []
deleteNoteTagsByNoteStmt.run(noteId)
const seen = new Set()
tagIds.forEach(tid => {
if (tid && !seen.has(tid)) {
seen.add(tid)
insertNoteTagStmt.run(noteId, tid)
}
})
return { note_id: noteId, version_id: versionId, conflict: false }
}
const listVersions = noteId => listVersionsStmt.all(noteId)
const createAttachment = payload => {
const info = createAttachmentStmt.run(payload)
return info.lastInsertRowid
}
const listAttachments = noteId => listAttachmentsStmt.all(noteId)
const deleteAttachment = id => deleteAttachmentStmt.run(parseInt(id, 10))
const deleteAttachmentWithFile = id => {
const aid = parseInt(id, 10)
if (!aid) return
const row = getAttachmentByIdStmt.get(aid)
if (row && row.file_path) {
try {
const refs = countAttachmentByPathStmt.get(row.file_path)
const c = refs && typeof refs.c === 'number' ? refs.c : 0
if (c <= 1) {
const rel = String(row.file_path || '').replace(/^[\\/]+/, '')
if (rel) {
const full = path.join(process.cwd(), rel)
if (fs.existsSync(full)) fs.unlinkSync(full)
}
}
} catch {}
}
deleteAttachmentStmt.run(aid)
}
const cloneAttachments = (srcNoteId, destNoteId) => {
const srcId = parseInt(srcNoteId, 10)
const dstId = parseInt(destNoteId, 10)
if (!srcId || !dstId || srcId === dstId) return
const atts = listAttachmentsStmt.all(srcId)
atts.forEach(a => {
createAttachmentStmt.run({
note_id: dstId,
filename: a.filename,
file_path: a.file_path,
file_size: a.file_size
})
})
}
const listNotes = userId => listNotesStmt.all(userId)
const getNote = id => {
const row = getNoteStmt.get(id)
if (!row) return null
const rel = listNoteTagsStmt.all(id)
row.tag_ids = rel.map(r => r.tag_id)
const latest = latestVersionStmt.get(id)
row.version_id = latest && latest.id ? latest.id : 0
row.version_saved_at = latest && latest.saved_at ? latest.saved_at : ''
return row
}
const searchNotes = (userId, keyword) => {
const kw = String(keyword || '').trim()
if (!kw) return []
const pat = `%${kw}%`
return searchNotesStmt.all(userId, pat, pat)
}
const setStar = payload => setStarStmt.run({ id: parseInt(payload.id,10), starred: payload.starred ? 1 : 0 })
const setPin = payload => setPinStmt.run({ id: parseInt(payload.id,10), pinned: payload.pinned ? 1 : 0 })
const listTrashNotes = userId => listTrashNotesStmt.all(userId)
const softDeleteNote = id => softDeleteNoteStmt.run({ id: parseInt(id, 10) })
const restoreNote = id => restoreNoteStmt.run({ id: parseInt(id, 10) })
const listTags = userId => listTagsStmt.all(userId)
const saveTags = (userId, tags) => {
const uid = parseInt(userId || '1', 10)
const existing = listTagsStmt.all(uid)
const existingById = new Map()
existing.forEach(t => { existingById.set(t.id, t) })
const tx = db.transaction(() => {
const keep = new Set()
const list = Array.isArray(tags) ? tags : []
list.forEach((t, idx) => {
const id = parseInt(t.id || '0', 10)
const name = String(t.name || '').trim()
const color = String(t.color || '').trim()
const sort_index = Number.isFinite(t.sort_index) ? t.sort_index : idx
if (!name) return
if (id && existingById.has(id)) {
updateTagStmt.run({ id, user_id: uid, name, color, sort_index })
keep.add(id)
} else {
const info = insertTagStmt.run({ user_id: uid, name, color, sort_index })
keep.add(info.lastInsertRowid)
}
})
existing.forEach(t => {
if (!keep.has(t.id)) {
deleteNoteTagsByTagStmt.run(t.id)
deleteTagStmt.run(t.id, uid)
}
})
})
tx()
}
const listNotesByTag = (userId, tagId) => {
const uid = parseInt(userId || '1', 10)
const tid = parseInt(tagId || '0', 10)
if (!tid) return []
return listNotesByTagStmt.all(uid, tid)
}
const dashboard = userId => {
const uid = parseInt(userId || '1', 10)
const notes = listNotesStmt.all(uid)
const now = new Date()
const todayStr = now.toISOString().slice(0, 10)
const weekAgoMs = now.getTime() - 6 * 24 * 3600 * 1000
let todayCount = 0
let weekCount = 0
let starredCount = 0
let pinnedCount = 0
let lastNoteId = 0
let lastNoteUpdated = 0
notes.forEach(n => {
const updated = String(n.updated_at || n.created_at || '')
if (updated.startsWith(todayStr)) todayCount++
const t = Date.parse(updated.replace(' ', 'T'))
if (!Number.isNaN(t) && t >= weekAgoMs) weekCount++
if (n.starred) starredCount++
if (n.pinned) pinnedCount++
if (!Number.isNaN(t) && t > lastNoteUpdated) {
lastNoteUpdated = t
lastNoteId = n.id
}
})
const recentNotes = notes.slice(0, 6)
const pinned = notes.filter(n => n.pinned).slice(0, 6)
const starred = notes.filter(n => n.starred).slice(0, 6)
const tagStats = tagStatsStmt.all(uid)
const hotTags = tagStats.slice(0, 8)
const untaggedRow = untaggedCountStmt.get(uid)
const untaggedCount = untaggedRow && typeof untaggedRow.c === 'number' ? untaggedRow.c : 0
const recentAttachments = recentAttachmentsByUserStmt.all(uid)
return {
overview: {
total_notes: notes.length,
today_count: todayCount,
week_count: weekCount,
starred_count: starredCount,
pinned_count: pinnedCount
},
recent_notes: recentNotes,
pinned_notes: pinned,
starred_notes: starred,
hot_tags: hotTags,
untagged_count: untaggedCount,
recent_attachments: recentAttachments,
last_note_id: lastNoteId
}
}
const hardDeleteNote = id => {
const nid = parseInt(id, 10)
if (!nid) return
const atts = listAttachmentsStmt.all(nid)
atts.forEach(a => {
try {
const rel = String(a.file_path || '').replace(/^[\\/]+/, '')
if (rel) {
const refs = countAttachmentByPathStmt.get(a.file_path)
const c = refs && typeof refs.c === 'number' ? refs.c : 0
if (c <= 1) {
const full = path.join(process.cwd(), rel)
if (fs.existsSync(full)) fs.unlinkSync(full)
}
}
} catch {}
deleteAttachmentStmt.run(parseInt(a.id, 10))
})
hardDeleteNoteStmt.run({ id: nid })
deleteNoteTagsByNoteStmt.run(nid)
}
module.exports = { login, saveNote, listVersions, createAttachment, listAttachments, deleteAttachment, deleteAttachmentWithFile, listNotes, getNote, searchNotes, setStar, setPin, listTrashNotes, softDeleteNote, restoreNote, hardDeleteNote, listTags, saveTags, listNotesByTag, dashboard, cloneAttachments, getTinyToolbarSettings, saveTinyToolbarSettings }