diff --git a/.env.example b/.env.example index e3cbe39..9479354 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ TZ=UTC # Copy this file to .env and customize # Image version to run (set by build.sh, or use 'latest') -JAMA_VERSION=0.8.8 +JAMA_VERSION=0.9.1 # Default admin credentials (used on FIRST RUN only) ADMIN_NAME=Admin User @@ -24,6 +24,13 @@ ADMPW_RESET=false # JWT secret - change this to a random string in production! JWT_SECRET=changeme_super_secret_jwt_key_change_in_production +# Database encryption key (SQLCipher AES-256) +# Generate a strong random key: openssl rand -hex 32 +# IMPORTANT: If you are upgrading from an unencrypted install, run the +# migration script first: node scripts/encrypt-db.js +# Leave blank to run without encryption (not recommended for production) +DB_KEY= + # App port (default 3000) PORT=3000 diff --git a/Dockerfile b/Dockerfile index 8e6970b..8b42aaa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ LABEL org.opencontainers.image.title="jama" \ ENV JAMA_VERSION=${VERSION} -RUN apk add --no-cache sqlite python3 make g++ +RUN apk add --no-cache sqlite sqlcipher python3 make g++ openssl-dev WORKDIR /app diff --git a/backend/package.json b/backend/package.json index d6bca30..4e028dc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.8.8", + "version": "0.9.1", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { @@ -9,7 +9,6 @@ }, "dependencies": { "bcryptjs": "^2.4.3", - "better-sqlite3": "^9.4.3", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "express": "^4.18.2", @@ -19,7 +18,8 @@ "node-fetch": "^2.7.0", "sharp": "^0.33.2", "socket.io": "^4.6.1", - "web-push": "^3.6.7" + "web-push": "^3.6.7", + "better-sqlite3-sqlcipher": "^9.4.3" }, "devDependencies": { "nodemon": "^3.0.2" diff --git a/backend/scripts/encrypt-db.js b/backend/scripts/encrypt-db.js new file mode 100644 index 0000000..591645c --- /dev/null +++ b/backend/scripts/encrypt-db.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * jama DB encryption migration + * ───────────────────────────────────────────────────────────────────────────── + * Converts an existing plain SQLite database to SQLCipher (AES-256 encrypted). + * + * Run ONCE before upgrading to a jama version that includes DB_KEY support. + * The container must be STOPPED before running this script. + * + * Usage (run on the Docker host, not inside the container): + * + * node encrypt-db.js --db /path/to/jama.db --key YOUR_DB_KEY + * + * Or using env vars: + * + * DB_PATH=/path/to/jama.db DB_KEY=yourkey node encrypt-db.js + * + * To find your Docker volume path: + * docker volume inspect jama_jama_db + * (look for the "Mountpoint" field) + * + * The script will: + * 1. Verify the source file is a plain (unencrypted) SQLite database + * 2. Create an encrypted copy at .encrypted + * 3. Back up the original to .plaintext-backup + * 4. Move the encrypted copy into place as + * + * If anything goes wrong, restore with: + * cp jama.db.plaintext-backup jama.db + * ───────────────────────────────────────────────────────────────────────────── + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// Parse CLI args --db and --key +const args = process.argv.slice(2); +const argDb = args[args.indexOf('--db') + 1]; +const argKey = args[args.indexOf('--key') + 1]; + +const DB_PATH = argDb || process.env.DB_PATH || '/app/data/jama.db'; +const DB_KEY = argKey || process.env.DB_KEY || ''; + +// ── Validation ──────────────────────────────────────────────────────────────── + +if (!DB_KEY) { + console.error('ERROR: No DB_KEY provided.'); + console.error('Usage: node encrypt-db.js --db /path/to/jama.db --key YOUR_KEY'); + console.error(' or: DB_KEY=yourkey node encrypt-db.js'); + process.exit(1); +} + +if (!fs.existsSync(DB_PATH)) { + console.error(`ERROR: Database file not found: ${DB_PATH}`); + process.exit(1); +} + +// Check it looks like a plain SQLite file (magic bytes: "SQLite format 3\000") +const MAGIC = 'SQLite format 3\0'; +const fd = fs.openSync(DB_PATH, 'r'); +const header = Buffer.alloc(16); +fs.readSync(fd, header, 0, 16, 0); +fs.closeSync(fd); + +if (header.toString('ascii') !== MAGIC) { + console.error('ERROR: The database does not appear to be a plain (unencrypted) SQLite file.'); + console.error('It may already be encrypted, or the path is wrong.'); + process.exit(1); +} + +// ── Migration ───────────────────────────────────────────────────────────────── + +let Database; +try { + Database = require('better-sqlite3-sqlcipher'); +} catch (e) { + console.error('ERROR: better-sqlite3-sqlcipher is not installed.'); + console.error('Run: npm install better-sqlite3-sqlcipher'); + process.exit(1); +} + +const encPath = DB_PATH + '.encrypted'; +const backupPath = DB_PATH + '.plaintext-backup'; + +console.log(`\njama DB encryption migration`); +console.log(`────────────────────────────`); +console.log(`Source: ${DB_PATH}`); +console.log(`Backup: ${backupPath}`); +console.log(`Output: ${DB_PATH} (encrypted)\n`); + +try { + // Open the plain DB (no key) + console.log('Step 1/4 Opening plain database...'); + const plain = new Database(DB_PATH); + + // Create the encrypted copy using SQLCipher ATTACH + sqlcipher_export + console.log('Step 2/4 Encrypting to temporary file...'); + const safeKey = DB_KEY.replace(/'/g, "''"); + plain.exec(` + ATTACH DATABASE '${encPath}' AS encrypted KEY '${safeKey}'; + SELECT sqlcipher_export('encrypted'); + DETACH DATABASE encrypted; + `); + plain.close(); + + // Verify the encrypted file opens correctly + console.log('Step 3/4 Verifying encrypted database...'); + const enc = new Database(encPath); + enc.pragma(`key = '${safeKey}'`); + const count = enc.prepare("SELECT COUNT(*) as n FROM sqlite_master").get(); + enc.close(); + console.log(` OK — ${count.n} objects found in encrypted DB`); + + // Swap files: backup plain, move encrypted into place + console.log('Step 4/4 Swapping files...'); + fs.renameSync(DB_PATH, backupPath); + fs.renameSync(encPath, DB_PATH); + + console.log(`\n✓ Migration complete!`); + console.log(` Encrypted DB: ${DB_PATH}`); + console.log(` Plain backup: ${backupPath}`); + console.log(`\nNext steps:`); + console.log(` 1. Set DB_KEY=${DB_KEY} in your .env file`); + console.log(` 2. Start jama — it will open the encrypted database`); + console.log(` 3. Once confirmed working, delete the plain backup:`); + console.log(` rm ${backupPath}\n`); + +} catch (err) { + console.error(`\n✗ Migration failed: ${err.message}`); + // Clean up any partial encrypted file + if (fs.existsSync(encPath)) fs.unlinkSync(encPath); + console.error('No changes were made to the original database.'); + process.exit(1); +} diff --git a/backend/src/models/db.js b/backend/src/models/db.js index b993129..67e4387 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -1,9 +1,10 @@ -const Database = require('better-sqlite3'); +const Database = require('better-sqlite3-sqlcipher'); const path = require('path'); const fs = require('fs'); const bcrypt = require('bcryptjs'); const DB_PATH = process.env.DB_PATH || '/app/data/jama.db'; +const DB_KEY = process.env.DB_KEY || ''; let db; @@ -16,6 +17,13 @@ function getDb() { console.log(`[DB] Created data directory: ${dir}`); } db = new Database(DB_PATH); + if (DB_KEY) { + // Apply encryption key — must be the very first pragma before any other DB access + db.pragma(`key = '${DB_KEY.replace(/'/g, "''")}'`); + console.log('[DB] Encryption key applied'); + } else { + console.warn('[DB] WARNING: DB_KEY not set — database is unencrypted'); + } db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); console.log(`[DB] Opened database at ${DB_PATH}`); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 6bd2792..388b0e7 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -99,11 +99,29 @@ router.post('/support', (req, res) => { ${message.trim()}`; - db.prepare(` + const msgResult = db.prepare(` INSERT INTO messages (group_id, user_id, content, type) VALUES (?, ?, ?, 'text') `).run(groupId, admin.id, content); + // Emit socket event so online admins see the message immediately + const newMsg = db.prepare(` + SELECT m.*, u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar + FROM messages m JOIN users u ON m.user_id = u.id + WHERE m.id = ? + `).get(msgResult.lastInsertRowid); + + if (newMsg) { + newMsg.reactions = []; + io.to(`group:${groupId}`).emit('message:new', newMsg); + } + + // Notify each admin via their user channel so they can reload groups if needed + const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all(); + for (const a of admins) { + io.to(`user:${a.id}`).emit('notification:new', { type: 'support', groupId }); + } + console.log(`[Support] Message from ${email} posted to Support group`); res.json({ success: true }); }); diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index 09269eb..219396c 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -53,11 +53,11 @@ router.get('/', authMiddleware, (req, res) => { (SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count, (SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message, (SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at, - (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id, + (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id FROM groups g WHERE g.type = 'public' ORDER BY g.is_default DESC, g.name ASC - `).all(userId); + `).all(); // For direct messages, replace name with opposite user's display name const privateGroupsRaw = db.prepare(` @@ -66,7 +66,7 @@ router.get('/', authMiddleware, (req, res) => { (SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count, (SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message, (SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at, - (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id, + (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id FROM groups g JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ? LEFT JOIN users u ON g.owner_id = u.id diff --git a/build.sh b/build.sh index 93a661f..6b9a8e8 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.8.8}" +VERSION="${1:-0.9.1}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/docker-compose.yaml b/docker-compose.yaml index efa593c..940d55b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -18,6 +18,7 @@ services: - JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024} - APP_NAME=${APP_NAME:-jama} - DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat} + - DB_KEY=${DB_KEY:-} volumes: - jama_db:/app/data - jama_uploads:/app/uploads diff --git a/frontend/package.json b/frontend/package.json index b197ef1..b4e94b0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.8.8", + "version": "0.9.1", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index 15d6343..96750ab 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -154,6 +154,9 @@ export default function Chat() { if (notif.type === 'private_message') { // Badge is already handled by handleNewMsg via message:new socket event. // Nothing to do here for the socket path. + } else if (notif.type === 'support') { + // A support request was submitted — reload groups so Support group appears in sidebar + loadGroups(); } else { setNotifications(prev => [notif, ...prev]); toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000);