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 }