v0.9.1 encrypt database and bug fixes
This commit is contained in:
@@ -7,7 +7,7 @@ TZ=UTC
|
|||||||
# Copy this file to .env and customize
|
# Copy this file to .env and customize
|
||||||
|
|
||||||
# Image version to run (set by build.sh, or use 'latest')
|
# 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)
|
# Default admin credentials (used on FIRST RUN only)
|
||||||
ADMIN_NAME=Admin User
|
ADMIN_NAME=Admin User
|
||||||
@@ -24,6 +24,13 @@ ADMPW_RESET=false
|
|||||||
# JWT secret - change this to a random string in production!
|
# JWT secret - change this to a random string in production!
|
||||||
JWT_SECRET=changeme_super_secret_jwt_key_change_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)
|
# App port (default 3000)
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ LABEL org.opencontainers.image.title="jama" \
|
|||||||
|
|
||||||
ENV JAMA_VERSION=${VERSION}
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.8.8",
|
"version": "0.9.1",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^9.4.3",
|
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
@@ -19,7 +18,8 @@
|
|||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
"socket.io": "^4.6.1",
|
"socket.io": "^4.6.1",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7",
|
||||||
|
"better-sqlite3-sqlcipher": "^9.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
|
|||||||
136
backend/scripts/encrypt-db.js
Normal file
136
backend/scripts/encrypt-db.js
Normal file
@@ -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 <original>.encrypted
|
||||||
|
* 3. Back up the original to <original>.plaintext-backup
|
||||||
|
* 4. Move the encrypted copy into place as <original>
|
||||||
|
*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3-sqlcipher');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
const DB_PATH = process.env.DB_PATH || '/app/data/jama.db';
|
const DB_PATH = process.env.DB_PATH || '/app/data/jama.db';
|
||||||
|
const DB_KEY = process.env.DB_KEY || '';
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
@@ -16,6 +17,13 @@ function getDb() {
|
|||||||
console.log(`[DB] Created data directory: ${dir}`);
|
console.log(`[DB] Created data directory: ${dir}`);
|
||||||
}
|
}
|
||||||
db = new Database(DB_PATH);
|
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('journal_mode = WAL');
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
console.log(`[DB] Opened database at ${DB_PATH}`);
|
console.log(`[DB] Opened database at ${DB_PATH}`);
|
||||||
|
|||||||
@@ -99,11 +99,29 @@ router.post('/support', (req, res) => {
|
|||||||
|
|
||||||
${message.trim()}`;
|
${message.trim()}`;
|
||||||
|
|
||||||
db.prepare(`
|
const msgResult = db.prepare(`
|
||||||
INSERT INTO messages (group_id, user_id, content, type)
|
INSERT INTO messages (group_id, user_id, content, type)
|
||||||
VALUES (?, ?, ?, 'text')
|
VALUES (?, ?, ?, 'text')
|
||||||
`).run(groupId, admin.id, content);
|
`).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`);
|
console.log(`[Support] Message from ${email} posted to Support group`);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 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.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.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
|
FROM groups g
|
||||||
WHERE g.type = 'public'
|
WHERE g.type = 'public'
|
||||||
ORDER BY g.is_default DESC, g.name ASC
|
ORDER BY g.is_default DESC, g.name ASC
|
||||||
`).all(userId);
|
`).all();
|
||||||
|
|
||||||
// For direct messages, replace name with opposite user's display name
|
// For direct messages, replace name with opposite user's display name
|
||||||
const privateGroupsRaw = db.prepare(`
|
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 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.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.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
|
FROM groups g
|
||||||
JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ?
|
JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ?
|
||||||
LEFT JOIN users u ON g.owner_id = u.id
|
LEFT JOIN users u ON g.owner_id = u.id
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.8.8}"
|
VERSION="${1:-0.9.1}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ services:
|
|||||||
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
||||||
- APP_NAME=${APP_NAME:-jama}
|
- APP_NAME=${APP_NAME:-jama}
|
||||||
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
||||||
|
- DB_KEY=${DB_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- jama_db:/app/data
|
- jama_db:/app/data
|
||||||
- jama_uploads:/app/uploads
|
- jama_uploads:/app/uploads
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.8.8",
|
"version": "0.9.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ export default function Chat() {
|
|||||||
if (notif.type === 'private_message') {
|
if (notif.type === 'private_message') {
|
||||||
// Badge is already handled by handleNewMsg via message:new socket event.
|
// Badge is already handled by handleNewMsg via message:new socket event.
|
||||||
// Nothing to do here for the socket path.
|
// 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 {
|
} else {
|
||||||
setNotifications(prev => [notif, ...prev]);
|
setNotifications(prev => [notif, ...prev]);
|
||||||
toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000);
|
toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000);
|
||||||
|
|||||||
Reference in New Issue
Block a user