diff --git a/.gitignore b/.gitignore index c837f93..5663bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ backend/db.sqlite backend/uploads/ .DS_Store frontend/dist/ -frontend-vue/dist \ No newline at end of file +frontend-vue/dist +.npm/** diff --git a/backend/src/app.ts b/backend/src/app.ts index 1b9b56f..1206934 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,6 +4,7 @@ import * as ChannelRoutes from "./routes/channel"; import * as FileRoutes from "./routes/file"; import * as MessageRoutes from "./routes/message"; import * as SearchRoutes from "./routes/search"; +import * as BackupRoutes from "./routes/backup"; import { authenticate } from "./middleware/auth"; import { initializeDB } from "./db"; import { FRONTEND_DIR, UPLOAD_DIR } from "./config"; @@ -20,6 +21,7 @@ app.use("/channels", ChannelRoutes.router); app.use("/channels/:channelId/messages", MessageRoutes.router); app.use("/channels/:channelId/messages/:messageId/files", FileRoutes.router); app.use("/search", SearchRoutes.router); +app.use("/backup", BackupRoutes.router); app.get('/check-token', authenticate, (req, res) => { res.json({ message: 'Token is valid' }); diff --git a/backend/src/routes/backup.ts b/backend/src/routes/backup.ts new file mode 100644 index 0000000..d7206da --- /dev/null +++ b/backend/src/routes/backup.ts @@ -0,0 +1,162 @@ +import { Router, type Request, type Response } from 'express'; +import { authenticate } from '../middleware/auth'; +import { db, FTS5Enabled } from '../db'; +import { DB_PATH } from '../config'; +import { logger } from '../globals'; +import Database from 'better-sqlite3'; +import multer from 'multer'; +import { unlink } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +export const router = Router(); + +const upload = multer({ dest: tmpdir() }); + +// GET /backup - Download the entire database as a .db file +router.get('/', authenticate, async (req: Request, res: Response) => { + try { + logger.info('Creating database backup...'); + + // Use better-sqlite3's backup API to create a safe copy + const backupPath = join(tmpdir(), `notebrook-backup-${Date.now()}.db`); + await db.backup(backupPath); + + const timestamp = new Date().toISOString().split('T')[0]; + const filename = `notebrook-backup-${timestamp}.db`; + + res.download(backupPath, filename, async (err) => { + // Clean up temp file after download + try { + await unlink(backupPath); + } catch (e) { + logger.warn(`Failed to clean up backup temp file: ${e}`); + } + + if (err) { + logger.critical(`Backup download error: ${err}`); + } else { + logger.info('Backup download completed'); + } + }); + } catch (error) { + logger.critical(`Backup failed: ${error}`); + res.status(500).json({ error: 'Failed to create backup' }); + } +}); + +// POST /restore - Upload a .db file and restore the database +router.post('/', authenticate, upload.single('database'), async (req: Request, res: Response) => { + if (!req.file) { + return res.status(400).json({ error: 'No database file provided' }); + } + + const uploadedPath = req.file.path; + + try { + logger.info(`Restoring database from uploaded file: ${uploadedPath}`); + + // Open the uploaded database to validate and read data + const uploadedDb = new Database(uploadedPath, { readonly: true }); + + // Validate that it has the expected tables + const tables = uploadedDb.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]; + const tableNames = tables.map(t => t.name); + + if (!tableNames.includes('channels') || !tableNames.includes('messages')) { + uploadedDb.close(); + await unlink(uploadedPath); + return res.status(400).json({ error: 'Invalid backup file: missing required tables' }); + } + + // Read all data from uploaded database + const channels = uploadedDb.prepare('SELECT * FROM channels').all(); + const messages = uploadedDb.prepare('SELECT * FROM messages').all(); + const files = tableNames.includes('files') + ? uploadedDb.prepare('SELECT * FROM files').all() + : []; + const meta = tableNames.includes('meta') + ? uploadedDb.prepare('SELECT * FROM meta').all() + : []; + + uploadedDb.close(); + + // Begin transaction to restore data + const transaction = db.transaction(() => { + // Clear existing data (order matters due to foreign keys) + if (FTS5Enabled) { + db.exec('DELETE FROM messages_fts'); + } + db.exec('DELETE FROM messages'); + db.exec('DELETE FROM files'); + db.exec('DELETE FROM channels'); + + // Reset auto-increment counters + db.exec(`DELETE FROM sqlite_sequence WHERE name IN ('channels', 'messages', 'files')`); + + // Insert channels + if (channels.length > 0) { + const insertChannel = db.prepare(` + INSERT INTO channels (id, name, created_at) VALUES (@id, @name, @created_at) + `); + for (const channel of channels) { + insertChannel.run(channel); + } + } + + // Insert files first (messages reference files) + if (files.length > 0) { + const insertFile = db.prepare(` + INSERT INTO files (id, channel_id, file_path, file_type, file_size, original_name, created_at) + VALUES (@id, @channel_id, @file_path, @file_type, @file_size, @original_name, @created_at) + `); + for (const file of files) { + insertFile.run(file); + } + } + + // Insert messages + if (messages.length > 0) { + const insertMessage = db.prepare(` + INSERT INTO messages (id, channel_id, content, file_id, checked, created_at) + VALUES (@id, @channel_id, @content, @file_id, @checked, @created_at) + `); + for (const message of messages) { + insertMessage.run(message); + } + + // Rebuild FTS index + if (FTS5Enabled) { + db.exec(`INSERT INTO messages_fts(messages_fts) VALUES('rebuild')`); + } + } + }); + + transaction(); + + // Clean up uploaded file + await unlink(uploadedPath); + + logger.info('Database restore completed successfully'); + res.json({ + success: true, + message: 'Database restored successfully', + stats: { + channels: channels.length, + messages: messages.length, + files: files.length + } + }); + } catch (error) { + logger.critical(`Restore failed: ${error}`); + + // Clean up uploaded file on error + try { + await unlink(uploadedPath); + } catch (e) { + // Ignore cleanup errors + } + + res.status(500).json({ error: 'Failed to restore database: ' + (error as Error).message }); + } +}); diff --git a/backend/src/server.ts b/backend/src/server.ts index 58f8e1a..4d4a2b3 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -43,10 +43,9 @@ const getOrCreateCertificate = async () => { } const createSelfSignedSSLCert = async () => { - const selfsigned = await import('selfsigned'); - const pems = selfsigned.generate([{ name: 'Notebrook Self Signed Auto Generated Key', value: 'localhost' }], { + const pems = await selfSigned.generate([{ name: 'commonName', value: 'localhost' }], { keySize: 2048, - days: 365 + algorithm: 'sha256' }); return { key: pems.private, diff --git a/frontend-vue/package-lock.json b/frontend-vue/package-lock.json index c31d46f..34bfb71 100644 --- a/frontend-vue/package-lock.json +++ b/frontend-vue/package-lock.json @@ -8,9 +8,11 @@ "name": "notebrook-frontend-vue", "version": "1.0.0", "dependencies": { + "@types/jszip": "^3.4.0", "@vueuse/core": "^14.1.0", "@vueuse/sound": "^2.1.3", "idb-keyval": "^6.2.2", + "jszip": "^3.10.1", "pinia": "^3.0.4", "vue": "^3.5.25", "vue-router": "^4.6.3" @@ -2859,6 +2861,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", @@ -4384,6 +4395,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5717,6 +5734,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5744,6 +5767,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -6358,6 +6387,18 @@ "node": ">=0.10.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6392,6 +6433,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6699,6 +6749,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6932,6 +6988,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6973,6 +7035,33 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7345,6 +7434,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7539,6 +7634,21 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -8325,7 +8435,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vite": { diff --git a/frontend-vue/package.json b/frontend-vue/package.json index 5c220a7..4b14459 100644 --- a/frontend-vue/package.json +++ b/frontend-vue/package.json @@ -12,27 +12,29 @@ "format": "prettier --write src/" }, "dependencies": { - "vue": "^3.5.25", - "vue-router": "^4.6.3", - "pinia": "^3.0.4", - "idb-keyval": "^6.2.2", + "@types/jszip": "^3.4.0", "@vueuse/core": "^14.1.0", - "@vueuse/sound": "^2.1.3" + "@vueuse/sound": "^2.1.3", + "idb-keyval": "^6.2.2", + "jszip": "^3.10.1", + "pinia": "^3.0.4", + "vue": "^3.5.25", + "vue-router": "^4.6.3" }, "devDependencies": { "@types/node": "^24.10.1", - "@vitejs/plugin-vue": "^6.0.2", - "@vue/tsconfig": "^0.8.1", - "typescript": "^5.9.3", - "vite": "^7.2.6", - "vue-tsc": "^3.1.5", - "vite-plugin-pwa": "^1.2.0", "@typescript-eslint/eslint-plugin": "^8.48.1", "@typescript-eslint/parser": "^8.48.1", + "@vitejs/plugin-vue": "^6.0.2", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.6.0", + "@vue/tsconfig": "^0.8.1", "eslint": "^9.39.1", "eslint-plugin-vue": "^10.6.2", - "prettier": "^3.7.3" + "prettier": "^3.7.3", + "typescript": "^5.9.3", + "vite": "^7.2.6", + "vite-plugin-pwa": "^1.2.0", + "vue-tsc": "^3.1.5" } -} \ No newline at end of file +} diff --git a/frontend-vue/src/components/chat/MessageItem.vue b/frontend-vue/src/components/chat/MessageItem.vue index 89bfa0a..1573a3b 100644 --- a/frontend-vue/src/components/chat/MessageItem.vue +++ b/frontend-vue/src/components/chat/MessageItem.vue @@ -313,14 +313,22 @@ const handleFocus = () => { const toggleAriaLabel = computed(() => { if (isChecked.value === true) return 'Mark as unchecked' - if (isChecked.value === false) return 'Mark as checked' + if (isChecked.value === false) return 'Remove check' return 'Mark as checked' }) const toggleChecked = async () => { if (props.isUnsent) return const msg = props.message as ExtendedMessage - const next = isChecked.value !== true + // Cycle: null → true → false → null + let next: boolean | null + if (isChecked.value === null) { + next = true + } else if (isChecked.value === true) { + next = false + } else { + next = null + } const prev = isChecked.value try { // optimistic diff --git a/frontend-vue/src/components/dialogs/SettingsDialog.vue b/frontend-vue/src/components/dialogs/SettingsDialog.vue index 73cc260..5a69e21 100644 --- a/frontend-vue/src/components/dialogs/SettingsDialog.vue +++ b/frontend-vue/src/components/dialogs/SettingsDialog.vue @@ -145,17 +145,80 @@ - + +
+ Download a complete backup of all channels, messages, and data. Restore will replace all existing data. +
+ ++ Export all channels and messages in various formats. +
+ +