This commit is contained in:
2026-03-15 12:09:18 -04:00
parent c8b43dea99
commit b6a6989319
69 changed files with 10037 additions and 90 deletions

27
backend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "jama-backend",
"version": "0.9.23",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"nanoid": "^3.3.7",
"node-fetch": "^2.7.0",
"sharp": "^0.33.2",
"socket.io": "^4.6.1",
"web-push": "^3.6.7",
"better-sqlite3-multiple-ciphers": "^12.6.2"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

View 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-multiple-ciphers');
} 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 encrypted copy using sqlcipher_export via ATTACH
console.log('Step 2/4 Encrypting to temporary file...');
const safeKey = DB_KEY.replace(/'/g, "''");
plain.exec(`ATTACH DATABASE '${encPath}' AS encrypted KEY '${safeKey}'`);
plain.exec(`SELECT sqlcipher_export('encrypted')`);
plain.exec(`DETACH DATABASE encrypted`);
plain.close();
// Verify the encrypted file opens correctly with cipher settings
console.log('Step 3/4 Verifying encrypted database...');
const enc = new Database(encPath);
enc.pragma(`cipher='sqlcipher'`);
enc.pragma(`legacy=4`);
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);
}

134
backend/src/data/help.md Normal file
View File

@@ -0,0 +1,134 @@
# Getting Started with JAMA
Welcome to **JAMA** — your private, self-hosted team messaging app.
**JAMA** - **J**ust **A**nother **M**essaging **A**pp
---
## What is JAMA?
JAMA is a private chat system that doesnt need the internet to work—you can host it on a completely offline network. Even if you do run JAMA while you're online, it stays locked inside its own "container," so it never reaches out to other internet services.
We keep things private, too: the only info we ask for is a name and an email, and technically speaking they don't even have to be real. Your name just helps your team know who you are, and your email is only used as your login (it's never shares with anyone else).
Theres no annoying phone or email verification to deal with, so you can jump right in. If you ever get locked out, just hit the "Get Help" link on the login page. JAMA is easy and intuitive, you're going to love it.
----
----
## Security
### 🛡️ Your Privacy Assured
**Encryption**, the JAMA database is fully encrypted. Your posts are protected from prying eyes, including the JAMA administrators.
The only people that can read your direct messages (**person 2 person** or **group**) are the members of your message group. No one else knows, including JAMA admins, which direct message groups exist or which you are part of, well, unless they are a member of the group. With the database being encrypted there is no easy way to access your data.
**Every user**, at minimum, can read all public messages.
----
----
## Navigating JAMA
### Message List (Left Sidebar)
The sidebar shows all your message groups and direct conversations. Tap or click any group to open it.
- **#** prefix indicates a **Public** group — visible to all users
- **Bold** group names, with a notification badge means you have unread messages
- A message with the newest post with alway be listed at the top
- The last message preview shows a message from a user in your group, or **You:** if you sent it
## Sending Messages
Type your message in the input box at the bottom and press **Enter** to send.
- **Shift + Enter** adds a new line without sending
- Tap the **+** button to attach a photo or emoji
- Use the **camera** icon to take a photo directly (mobile only)
### Mentioning Someone
Type **@** will bring a group user list, select a users real name to mention them. Users receive a notification.
Example: `@[John Smith]` will notify John Smith of the message.
### Replying to a Message
Hover over any message and click the **reply arrow** in the pop-up to quote and reply to it.
### Reacting to a Message
Hover over any message and select a common emoji in the pop-up to or click the **emoji** button to bring up a full list to select from.
---
## Direct Messages
There are two ways to start a private conversation with one person:
_**New Chat Button**_
1. Click the **New Chat** icon in the sidebar
2. Select one user from the list
3. Click **Start Conversation**
_**Message Window**_
1. Click the users avatar in a message window to bring up the profile
2. Click **Direct Message**
> _Users have the ability to disable direct and private messages in their profile. If set, they will not be listed in the "New Chat" user list and the "Direct Message" button is not enabled._
---
## Group Messages
To create a group conversation:
1. Click the **new chat** icon
2. Select two or more users from the
3. Enter a **Message Name**
4. Click **Create**
> _If a message group with the exact same members already exists, you will be redirected to it automatically. This helps to avoid duplication._
_**Note:** Users have the option to leave any direct message group by selecting the "Message Info" button in the top right corner in the message title._
---
## Your Profile
Click your name or avatar at the bottom of the sidebar to:
- Update your **display name** (displayed in message windows)
- Add an **about me** note
- Upload a **profile photo** for your avatar
- Change your **password**
---
## Customising Group Names
You can set a personal display name for any group that only you will see:
1. Open the message
2. Click the **message info** icon in the top right
3. Enter your custom name under **Your custom name**
4. Click **Save**
Other members still see the original group name, unless they change to customised name for themselves.
---
## Admin Options
Admins can access **Settings** from the user menu to configure:
- **Branding:** a new app name and/or logo, title colour and message list avatar background colours
- **User Manager:** Create new user password, change passwords, suspend and delete user accounts.
- **Settings:** Various options
---
## Tips
- 🌙 Toggle **dark mode** from the user menu
- 🔔 Enable **push notifications** when prompted to receive alerts when the app is closed
- 📱 Install JAMA as a **PWA** on your device — tap *Add to Home Screen* in your browser menu for an app-like experience

363
backend/src/index.js Normal file
View File

@@ -0,0 +1,363 @@
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const path = require('path');
const jwt = require('jsonwebtoken');
const { initDb, seedAdmin, getOrCreateSupportGroup, getDb } = require('./models/db');
const { router: pushRouter, sendPushToUser } = require('./routes/push');
const { getLinkPreview } = require('./utils/linkPreview');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: { origin: '*', methods: ['GET', 'POST'] }
});
const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret';
const PORT = process.env.PORT || 3000;
// Init DB
initDb();
seedAdmin();
// Ensure Support group exists and all admins are members
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
const db = getDb();
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const a of admins) insert.run(supportGroupId, a.id);
}
// Middleware
app.use(cors());
app.use(express.json());
app.use(cookieParser());
app.use('/uploads', express.static('/app/uploads'));
// API Routes
app.use('/api/auth', require('./routes/auth')(io));
app.use('/api/users', require('./routes/users'));
app.use('/api/groups', require('./routes/groups')(io));
app.use('/api/messages', require('./routes/messages')(io));
app.use('/api/settings', require('./routes/settings'));
app.use('/api/about', require('./routes/about'));
app.use('/api/help', require('./routes/help'));
app.use('/api/push', pushRouter);
// Link preview proxy
app.get('/api/link-preview', async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'URL required' });
const preview = await getLinkPreview(url);
res.json({ preview });
});
// Health check
app.get('/api/health', (req, res) => res.json({ ok: true }));
// Dynamic manifest — must be before express.static so it takes precedence
app.get('/manifest.json', (req, res) => {
const db = getDb();
const rows = db.prepare("SELECT key, value FROM settings WHERE key IN ('app_name', 'logo_url', 'pwa_icon_192', 'pwa_icon_512')").all();
const s = {};
for (const r of rows) s[r.key] = r.value;
const appName = s.app_name || process.env.APP_NAME || 'jama';
const pwa192 = s.pwa_icon_192 || '';
const pwa512 = s.pwa_icon_512 || '';
// Use uploaded+resized icons if they exist, else fall back to bundled PNGs.
// Chrome requires explicit pixel sizes (not "any") to use icons for PWA shortcuts.
const icon192 = pwa192 || '/icons/icon-192.png';
const icon512 = pwa512 || '/icons/icon-512.png';
const icons = [
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'maskable' },
];
const manifest = {
name: appName,
short_name: appName.length > 12 ? appName.substring(0, 12) : appName,
description: `${appName} - Team messaging`,
start_url: '/',
scope: '/',
display: 'standalone',
orientation: 'portrait-primary',
background_color: '#ffffff',
theme_color: '#1a73e8',
icons,
};
res.setHeader('Content-Type', 'application/manifest+json');
res.setHeader('Cache-Control', 'no-cache');
res.json(manifest);
});
// Serve frontend
app.use(express.static(path.join(__dirname, '../public')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// Socket.io authentication
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (!token) return next(new Error('Unauthorized'));
try {
const decoded = jwt.verify(token, JWT_SECRET);
const db = getDb();
const user = db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active');
if (!user) return next(new Error('User not found'));
// Per-device enforcement: token must match an active session row
const session = db.prepare('SELECT * FROM active_sessions WHERE user_id = ? AND token = ?').get(decoded.id, token);
if (!session) return next(new Error('Session displaced'));
socket.user = user;
socket.token = token;
socket.device = session.device;
next();
} catch (e) {
next(new Error('Invalid token'));
}
});
// Track online users: userId -> Set of socketIds
const onlineUsers = new Map();
io.on('connection', (socket) => {
const userId = socket.user.id;
if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set());
onlineUsers.get(userId).add(socket.id);
// Record last_online timestamp
getDb().prepare("UPDATE users SET last_online = datetime('now') WHERE id = ?").run(userId);
// Broadcast online status
io.emit('user:online', { userId });
// Join personal room for direct notifications
socket.join(`user:${userId}`);
// Join rooms for all user's groups
const db = getDb();
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
for (const g of publicGroups) socket.join(`group:${g.id}`);
const privateGroups = db.prepare("SELECT group_id FROM group_members WHERE user_id = ?").all(userId);
for (const g of privateGroups) socket.join(`group:${g.group_id}`);
// When a new group is created and pushed to this socket, join its room
socket.on('group:join-room', ({ groupId }) => {
socket.join(`group:${groupId}`);
});
// When a user leaves a group, remove them from the socket room
socket.on('group:leave-room', ({ groupId }) => {
socket.leave(`group:${groupId}`);
});
// Handle new message
socket.on('message:send', async (data) => {
const { groupId, content, replyToId, imageUrl, linkPreview } = data;
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
if (!group) return;
if (group.is_readonly && socket.user.role !== 'admin') return;
// Check access
if (group.type === 'private') {
const member = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId);
if (!member) return;
}
const result = db.prepare(`
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id, link_preview)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(groupId, userId, content || null, imageUrl || null, imageUrl ? 'image' : 'text', replyToId || null, linkPreview ? JSON.stringify(linkPreview) : null);
const message = db.prepare(`
SELECT m.*,
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.status as user_status, u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me,
rm.content as reply_content, rm.image_url as reply_image_url, rm.is_deleted as reply_is_deleted,
ru.name as reply_user_name, ru.display_name as reply_user_display_name
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
message.reactions = [];
io.to(`group:${groupId}`).emit('message:new', message);
// For private groups: push notify members who are offline
// (reuse `group` already fetched above)
if (group?.type === 'private') {
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
const senderName = socket.user?.display_name || socket.user?.name || 'Someone';
for (const m of members) {
if (m.user_id === userId) continue; // don't notify sender
if (!onlineUsers.has(m.user_id)) {
// User is offline — send push
sendPushToUser(m.user_id, {
title: senderName,
body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100),
url: '/',
groupId,
badge: 1,
}).catch(() => {});
} else {
// User is online but not necessarily in this group — send socket notification
const notif = { type: 'private_message', groupId, fromUser: socket.user };
for (const sid of onlineUsers.get(m.user_id)) {
io.to(sid).emit('notification:new', notif);
}
}
}
}
// Process @mentions — format is @[display name], look up user by display_name or name
if (content) {
const mentionNames = [...new Set((content.match(/@\[([^\]]+)\]/g) || []).map(m => m.slice(2, -1)))];
for (const mentionName of mentionNames) {
const mentionedUser = db.prepare(
"SELECT id FROM users WHERE status = 'active' AND (LOWER(display_name) = LOWER(?) OR LOWER(name) = LOWER(?))"
).get(mentionName, mentionName);
const matchId = mentionedUser?.id?.toString();
if (matchId && parseInt(matchId) !== userId) {
const notifResult = db.prepare(`
INSERT INTO notifications (user_id, type, message_id, group_id, from_user_id)
VALUES (?, 'mention', ?, ?, ?)
`).run(parseInt(matchId), result.lastInsertRowid, groupId, userId);
// Notify mentioned user — socket if online, push if not
const mentionedUserId = parseInt(matchId);
const notif = {
id: notifResult.lastInsertRowid,
type: 'mention',
groupId,
messageId: result.lastInsertRowid,
fromUser: socket.user,
};
if (onlineUsers.has(mentionedUserId)) {
for (const sid of onlineUsers.get(mentionedUserId)) {
io.to(sid).emit('notification:new', notif);
}
}
// Always send push (badge even when app is open)
const senderName = socket.user?.display_name || socket.user?.name || 'Someone';
sendPushToUser(mentionedUserId, {
title: `${senderName} mentioned you`,
body: (content || '').replace(/@\[([^\]]+)\]/g, '@$1').slice(0, 100),
url: '/',
badge: 1,
}).catch(() => {});
}
}
}
});
// Handle reaction — one reaction per user; same emoji toggles off, different emoji replaces
socket.on('reaction:toggle', (data) => {
const { messageId, emoji } = data;
const db = getDb();
const message = db.prepare('SELECT m.*, g.id as gid FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ? AND m.is_deleted = 0').get(messageId);
if (!message) return;
// Find any existing reaction by this user on this message
const existing = db.prepare('SELECT * FROM reactions WHERE message_id = ? AND user_id = ?').get(messageId, userId);
if (existing) {
if (existing.emoji === emoji) {
// Same emoji — toggle off (remove)
db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id);
} else {
// Different emoji — replace
db.prepare('UPDATE reactions SET emoji = ? WHERE id = ?').run(emoji, existing.id);
}
} else {
// No existing reaction — insert
db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(messageId, userId, emoji);
}
const reactions = db.prepare(`
SELECT r.emoji, r.user_id, u.name as user_name
FROM reactions r JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(messageId);
io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId, reactions });
});
// Handle message delete
socket.on('message:delete', (data) => {
const { messageId } = data;
const db = getDb();
const message = db.prepare(`
SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_direct
FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?
`).get(messageId);
if (!message) return;
const isAdmin = socket.user.role === 'admin';
const isOwner = message.group_owner_id === userId;
const isAuthor = message.user_id === userId;
// Rules:
// 1. Author can always delete their own message
// 2. Admin can delete in any public group or any group they're a member of
// 3. Group owner can delete any message in their group
// 4. In direct messages: author + owner rules apply (no blanket block)
let canDelete = isAuthor || isOwner;
if (!canDelete && isAdmin) {
if (message.group_type === 'public') {
canDelete = true;
} else {
// Admin can delete in private/direct groups they're a member of
const membership = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(message.group_id, userId);
if (membership) canDelete = true;
}
}
if (!canDelete) return;
db.prepare("UPDATE messages SET is_deleted = 1, content = null, image_url = null WHERE id = ?").run(messageId);
io.to(`group:${message.group_id}`).emit('message:deleted', { messageId, groupId: message.group_id });
});
// Handle typing
socket.on('typing:start', ({ groupId }) => {
socket.to(`group:${groupId}`).emit('typing:start', { userId, groupId, user: socket.user });
});
socket.on('typing:stop', ({ groupId }) => {
socket.to(`group:${groupId}`).emit('typing:stop', { userId, groupId });
});
// Get online users
socket.on('users:online', () => {
socket.emit('users:online', { userIds: [...onlineUsers.keys()] });
});
// Handle disconnect
socket.on('disconnect', () => {
if (onlineUsers.has(userId)) {
onlineUsers.get(userId).delete(socket.id);
if (onlineUsers.get(userId).size === 0) {
onlineUsers.delete(userId);
getDb().prepare("UPDATE users SET last_online = datetime('now') WHERE id = ?").run(userId);
io.emit('user:offline', { userId });
}
}
});
});
server.listen(PORT, () => {
console.log(`jama server running on port ${PORT}`);
});

View File

@@ -0,0 +1,73 @@
const jwt = require('jsonwebtoken');
const { getDb } = require('../models/db');
const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret';
// Classify a User-Agent string into 'mobile' or 'desktop'.
// Tablets are treated as mobile (one shared slot).
function getDeviceClass(ua) {
if (!ua) return 'desktop';
const s = ua.toLowerCase();
if (/mobile|android(?!.*tablet)|iphone|ipod|blackberry|windows phone|opera mini|silk/.test(s)) return 'mobile';
if (/tablet|ipad|kindle|playbook|android/.test(s)) return 'mobile';
return 'desktop';
}
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token;
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const decoded = jwt.verify(token, JWT_SECRET);
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active');
if (!user) return res.status(401).json({ error: 'User not found or suspended' });
// Per-device enforcement: token must match an active session row
const session = db.prepare('SELECT * FROM active_sessions WHERE user_id = ? AND token = ?').get(decoded.id, token);
if (!session) {
return res.status(401).json({ error: 'Session expired. Please log in again.' });
}
req.user = user;
req.token = token;
req.device = session.device;
next();
} catch (e) {
return res.status(401).json({ error: 'Invalid token' });
}
}
function adminMiddleware(req, res, next) {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
next();
}
function generateToken(userId) {
return jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '30d' });
}
// Upsert the active session for this user+device class.
// Displaces any prior session on the same device class; the other device class is unaffected.
function setActiveSession(userId, token, userAgent) {
const db = getDb();
const device = getDeviceClass(userAgent);
db.prepare(`
INSERT INTO active_sessions (user_id, device, token, ua, created_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, device) DO UPDATE SET token = ?, ua = ?, created_at = datetime('now')
`).run(userId, device, token, userAgent || null, token, userAgent || null);
return device;
}
// Clear one device slot on logout, or all slots (no device arg) for suspend/delete
function clearActiveSession(userId, device) {
const db = getDb();
if (device) {
db.prepare('DELETE FROM active_sessions WHERE user_id = ? AND device = ?').run(userId, device);
} else {
db.prepare('DELETE FROM active_sessions WHERE user_id = ?').run(userId);
}
}
module.exports = { authMiddleware, adminMiddleware, generateToken, setActiveSession, clearActiveSession, getDeviceClass };

368
backend/src/models/db.js Normal file
View File

@@ -0,0 +1,368 @@
const Database = require('better-sqlite3-multiple-ciphers');
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;
function getDb() {
if (!db) {
// Ensure the data directory exists before opening the DB
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`[DB] Created data directory: ${dir}`);
}
db = new Database(DB_PATH);
if (DB_KEY) {
// Use SQLCipher4 AES-256-CBC — compatible with standard sqlcipher CLI and DB Browser
// Must be applied before any other DB access
const safeKey = DB_KEY.replace(/'/g, "''");
db.pragma(`cipher='sqlcipher'`);
db.pragma(`legacy=4`);
db.pragma(`key='${safeKey}'`);
console.log('[DB] Encryption key applied (SQLCipher4)');
} else {
console.warn('[DB] WARNING: DB_KEY not set — database is unencrypted');
}
const journalMode = db.pragma('journal_mode = WAL', { simple: true });
if (journalMode !== 'wal') {
console.warn(`[DB] WARNING: journal_mode is '${journalMode}', expected 'wal' — performance may be degraded`);
}
db.pragma('synchronous = NORMAL'); // safe with WAL, faster than FULL
db.pragma('cache_size = -8000'); // 8MB page cache
db.pragma('foreign_keys = ON');
console.log(`[DB] Opened database at ${DB_PATH} (journal=${journalMode})`);
}
return db;
}
function initDb() {
const db = getDb();
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
status TEXT NOT NULL DEFAULT 'active',
is_default_admin INTEGER NOT NULL DEFAULT 0,
must_change_password INTEGER NOT NULL DEFAULT 1,
avatar TEXT,
about_me TEXT,
display_name TEXT,
hide_admin_tag INTEGER NOT NULL DEFAULT 0,
allow_dm INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'public',
owner_id INTEGER,
is_default INTEGER NOT NULL DEFAULT 0,
is_readonly INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (owner_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS group_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(group_id, user_id),
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
content TEXT,
type TEXT NOT NULL DEFAULT 'text',
image_url TEXT,
reply_to_id INTEGER,
is_deleted INTEGER NOT NULL DEFAULT 0,
link_preview TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (reply_to_id) REFERENCES messages(id)
);
CREATE TABLE IF NOT EXISTS reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
emoji TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(message_id, user_id, emoji),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
message_id INTEGER,
group_id INTEGER,
from_user_id INTEGER,
is_read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS active_sessions (
user_id INTEGER NOT NULL,
device TEXT NOT NULL DEFAULT 'desktop',
token TEXT NOT NULL,
ua TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, device),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
device TEXT NOT NULL DEFAULT 'desktop',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, device),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
// Initialize default settings
const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
insertSetting.run('app_name', process.env.APP_NAME || 'jama');
insertSetting.run('logo_url', '');
insertSetting.run('pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false');
insertSetting.run('icon_newchat', '');
insertSetting.run('icon_groupinfo', '');
insertSetting.run('pwa_icon_192', '');
insertSetting.run('pwa_icon_512', '');
insertSetting.run('color_title', '');
insertSetting.run('color_title_dark', '');
insertSetting.run('color_avatar_public', '');
insertSetting.run('color_avatar_dm', '');
// Migration: add hide_admin_tag if upgrading from older version
try {
db.exec("ALTER TABLE users ADD COLUMN hide_admin_tag INTEGER NOT NULL DEFAULT 0");
console.log('[DB] Migration: added hide_admin_tag column');
} catch (e) { /* column already exists */ }
// Migration: add allow_dm if upgrading from older version
try {
db.exec("ALTER TABLE users ADD COLUMN allow_dm INTEGER NOT NULL DEFAULT 1");
console.log('[DB] Migration: added allow_dm column');
} catch (e) { /* column already exists */ }
// Migration: replace single-session active_sessions with per-device version
try {
const cols = db.prepare("PRAGMA table_info(active_sessions)").all().map(c => c.name);
if (!cols.includes('device')) {
db.exec("DROP TABLE IF EXISTS active_sessions");
db.exec(`
CREATE TABLE IF NOT EXISTS active_sessions (
user_id INTEGER NOT NULL,
device TEXT NOT NULL DEFAULT 'desktop',
token TEXT NOT NULL,
ua TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, device),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
console.log('[DB] Migration: rebuilt active_sessions for per-device sessions');
}
} catch (e) { console.error('[DB] active_sessions migration error:', e.message); }
// Migration: add is_direct for user-to-user direct messages
try {
db.exec("ALTER TABLE groups ADD COLUMN is_direct INTEGER NOT NULL DEFAULT 0");
console.log('[DB] Migration: added is_direct column');
} catch (e) { /* column already exists */ }
// Migration: store both peer IDs so direct-message names survive member leave
try {
db.exec("ALTER TABLE groups ADD COLUMN direct_peer1_id INTEGER");
console.log('[DB] Migration: added direct_peer1_id column');
} catch (e) { /* column already exists */ }
try {
db.exec("ALTER TABLE groups ADD COLUMN direct_peer2_id INTEGER");
console.log('[DB] Migration: added direct_peer2_id column');
} catch (e) { /* column already exists */ }
// Migration: last_online timestamp per user
try {
db.exec("ALTER TABLE users ADD COLUMN last_online TEXT");
console.log('[DB] Migration: added last_online column');
} catch (e) { /* column already exists */ }
// Migration: help_dismissed preference per user
try {
db.exec("ALTER TABLE users ADD COLUMN help_dismissed INTEGER NOT NULL DEFAULT 0");
console.log('[DB] Migration: added help_dismissed column');
} catch (e) { /* column already exists */ }
// Migration: user-customised group display names (per-user, per-group)
try {
db.exec(`
CREATE TABLE IF NOT EXISTS user_group_names (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (user_id, group_id)
)
`);
console.log('[DB] Migration: user_group_names table ready');
} catch (e) { console.error('[DB] user_group_names migration error:', e.message); }
// Migration: pinned conversations (per-user, pins a group to top of sidebar)
try {
db.exec(`
CREATE TABLE IF NOT EXISTS pinned_conversations (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
pinned_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
)
`);
console.log('[DB] Migration: pinned_conversations table ready');
} catch (e) { console.error('[DB] pinned_conversations migration error:', e.message); }
console.log('[DB] Schema initialized');
return db;
}
function seedAdmin() {
const db = getDb();
// Strip any surrounding quotes from env vars (common docker-compose mistake)
const adminEmail = (process.env.ADMIN_EMAIL || 'admin@jama.local').replace(/^["']|["']$/g, '').trim();
const adminName = (process.env.ADMIN_NAME || 'Admin User').replace(/^["']|["']$/g, '').trim();
const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim();
const pwReset = process.env.ADMPW_RESET === 'true';
console.log(`[DB] Checking for default admin (${adminEmail})...`);
const existing = db.prepare('SELECT * FROM users WHERE is_default_admin = 1').get();
if (!existing) {
try {
const hash = bcrypt.hashSync(adminPass, 10);
const result = db.prepare(`
INSERT INTO users (name, email, password, role, status, is_default_admin, must_change_password)
VALUES (?, ?, ?, 'admin', 'active', 1, 1)
`).run(adminName, adminEmail, hash);
console.log(`[DB] Default admin created: ${adminEmail} (id=${result.lastInsertRowid})`);
// Create default public group
const groupResult = db.prepare(`
INSERT INTO groups (name, type, is_default, owner_id)
VALUES (?, 'public', 1, ?)
`).run(process.env.DEFCHAT_NAME || 'General Chat', result.lastInsertRowid);
// Add admin to default group
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)')
.run(groupResult.lastInsertRowid, result.lastInsertRowid);
console.log(`[DB] Default group created: ${process.env.DEFCHAT_NAME || 'General Chat'}`);
seedSupportGroup();
} catch (err) {
console.error('[DB] ERROR creating default admin:', err.message);
}
return;
}
console.log(`[DB] Default admin already exists (id=${existing.id})`);
// Handle ADMPW_RESET
if (pwReset) {
const hash = bcrypt.hashSync(adminPass, 10);
db.prepare(`
UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now')
WHERE is_default_admin = 1
`).run(hash);
db.prepare("UPDATE settings SET value = 'true', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
console.log('[DB] Admin password reset via ADMPW_RESET=true');
} else {
db.prepare("UPDATE settings SET value = 'false', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
}
}
function addUserToPublicGroups(userId) {
const db = getDb();
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const g of publicGroups) {
insert.run(g.id, userId);
}
}
function seedSupportGroup() {
const db = getDb();
// Create the Support group if it doesn't exist
const existing = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get();
if (existing) return existing.id;
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
if (!admin) return null;
const result = db.prepare(`
INSERT INTO groups (name, type, owner_id, is_default)
VALUES ('Support', 'private', ?, 0)
`).run(admin.id);
const groupId = result.lastInsertRowid;
// Add all current admins to the Support group
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const a of admins) insert.run(groupId, a.id);
console.log('[DB] Support group created');
return groupId;
}
function getOrCreateSupportGroup() {
const db = getDb();
const group = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get();
if (group) return group.id;
return seedSupportGroup();
}
module.exports = { getDb, initDb, seedAdmin, seedSupportGroup, getOrCreateSupportGroup, addUserToPublicGroups };

View File

@@ -0,0 +1,43 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const ABOUT_FILE = '/app/data/about.json';
const DEFAULTS = {
built_with: 'Node.js · Express · Socket.io · SQLite · React · Vite · Claude.ai',
developer: 'Ricky Stretch',
license: 'AGPL 3.0',
license_url: 'https://www.gnu.org/licenses/agpl-3.0.html',
description: 'Self-hosted, privacy-first team messaging.',
};
// GET /api/about — public, no auth required
router.get('/', (req, res) => {
let overrides = {};
try {
if (fs.existsSync(ABOUT_FILE)) {
const raw = fs.readFileSync(ABOUT_FILE, 'utf8');
overrides = JSON.parse(raw);
}
} catch (e) {
console.warn('about.json parse error:', e.message);
}
// Version always comes from the runtime env (same source as Settings window)
const about = {
...DEFAULTS,
...overrides,
version: process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
// Always expose original app identity — not overrideable via about.json or settings
default_app_name: 'jama',
default_logo: '/icons/jama.png',
};
// Never expose docker_image — removed from UI
delete about.docker_image;
res.json({ about });
});
module.exports = router;

130
backend/src/routes/auth.js Normal file
View File

@@ -0,0 +1,130 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { getDb, getOrCreateSupportGroup } = require('../models/db');
const { generateToken, authMiddleware, setActiveSession, clearActiveSession } = require('../middleware/auth');
module.exports = function(io) {
const router = express.Router();
// Login
router.post('/login', (req, res) => {
const { email, password, rememberMe } = req.body;
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
if (user.status === 'suspended') {
const adminUser = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
return res.status(403).json({
error: 'suspended',
adminEmail: adminUser?.email
});
}
if (user.status === 'deleted') return res.status(403).json({ error: 'Account not found' });
const valid = bcrypt.compareSync(password, user.password);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const token = generateToken(user.id);
const ua = req.headers['user-agent'] || '';
const device = setActiveSession(user.id, token, ua); // displaces prior session on same device class
// Kick any live socket on the same device class — it now holds a stale token
if (io) {
io.to(`user:${user.id}`).emit('session:displaced', { device });
}
const { password: _, ...userSafe } = user;
res.json({
token,
user: userSafe,
mustChangePassword: !!user.must_change_password,
rememberMe: !!rememberMe
});
});
// Change password
router.post('/change-password', authMiddleware, (req, res) => {
const { currentPassword, newPassword } = req.body;
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!bcrypt.compareSync(currentPassword, user.password)) {
return res.status(400).json({ error: 'Current password is incorrect' });
}
if (newPassword.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const hash = bcrypt.hashSync(newPassword, 10);
db.prepare("UPDATE users SET password = ?, must_change_password = 0, updated_at = datetime('now') WHERE id = ?").run(hash, req.user.id);
res.json({ success: true });
});
// Get current user
router.get('/me', authMiddleware, (req, res) => {
const { password, ...user } = req.user;
res.json({ user });
});
// Logout — clear active session for this device class only
router.post('/logout', authMiddleware, (req, res) => {
clearActiveSession(req.user.id, req.device);
res.json({ success: true });
});
// Public support contact form — no auth required
router.post('/support', (req, res) => {
const { name, email, message } = req.body;
if (!name?.trim() || !email?.trim() || !message?.trim()) {
return res.status(400).json({ error: 'All fields are required' });
}
if (message.trim().length > 2000) {
return res.status(400).json({ error: 'Message too long (max 2000 characters)' });
}
const db = getDb();
// Get or create the Support group
const groupId = getOrCreateSupportGroup();
if (!groupId) return res.status(500).json({ error: 'Support group unavailable' });
// Find a system/admin user to post as (default admin)
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
if (!admin) return res.status(500).json({ error: 'No admin configured' });
// Format the support message
const content = `📬 **Support Request**
**Name:** ${name.trim()}
**Email:** ${email.trim()}
${message.trim()}`;
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 });
});
return router;
};

View File

@@ -0,0 +1,422 @@
const express = require('express');
const fs = require('fs');
const router = express.Router();
const { getDb } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
// Helper: emit group:new to all members of a group
function emitGroupNew(io, groupId) {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
if (!group) return;
if (group.type === 'public') {
io.emit('group:new', { group });
} else {
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
for (const m of members) {
io.to(`user:${m.user_id}`).emit('group:new', { group });
}
}
}
// Delete an uploaded image file from disk
function deleteImageFile(imageUrl) {
if (!imageUrl) return;
try {
const filePath = '/app' + imageUrl;
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch (e) {
console.warn('[Groups] Could not delete image file:', e.message);
}
}
// Helper: emit group:deleted to all members
function emitGroupDeleted(io, groupId, members) {
for (const uid of members) {
io.to(`user:${uid}`).emit('group:deleted', { groupId });
}
}
// Helper: emit group:updated to all members
function emitGroupUpdated(io, groupId) {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
if (!group) return;
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
const uids = group.type === 'public'
? db.prepare("SELECT id as user_id FROM users WHERE status = 'active'").all()
: members;
for (const m of uids) {
io.to(`user:${m.user_id}`).emit('group:updated', { group });
}
}
// Inject io into routes
module.exports = (io) => {
// Get all groups for current user
router.get('/', authMiddleware, (req, res) => {
const db = getDb();
const userId = req.user.id;
const publicGroups = db.prepare(`
SELECT g.*,
(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
FROM groups g
WHERE g.type = 'public'
ORDER BY g.is_default DESC, g.name ASC
`).all();
// For direct messages, replace name with opposite user's display name
const privateGroupsRaw = db.prepare(`
SELECT g.*,
u.name as owner_name,
(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
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
WHERE g.type = 'private'
ORDER BY last_message_at DESC NULLS LAST
`).all(userId);
// For direct groups, set the name to the other user's display name
// Uses direct_peer1_id / direct_peer2_id so the name survives after a user leaves
const privateGroups = privateGroupsRaw.map(g => {
if (g.is_direct) {
// Backfill peer IDs for groups created before this migration
if (!g.direct_peer1_id || !g.direct_peer2_id) {
const peers = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? LIMIT 2').all(g.id);
if (peers.length === 2) {
db.prepare('UPDATE groups SET direct_peer1_id = ?, direct_peer2_id = ? WHERE id = ?')
.run(peers[0].user_id, peers[1].user_id, g.id);
g.direct_peer1_id = peers[0].user_id;
g.direct_peer2_id = peers[1].user_id;
}
}
const otherUserId = g.direct_peer1_id === userId ? g.direct_peer2_id : g.direct_peer1_id;
if (otherUserId) {
const other = db.prepare('SELECT display_name, name, avatar FROM users WHERE id = ?').get(otherUserId);
if (other) {
g.peer_id = otherUserId;
g.peer_real_name = other.name;
g.peer_display_name = other.display_name || null; // null if no custom display name set
g.peer_avatar = other.avatar || null;
g.name = other.display_name || other.name;
}
}
}
// Apply user's custom group name if set
const custom = db.prepare('SELECT name FROM user_group_names WHERE user_id = ? AND group_id = ?').get(userId, g.id);
if (custom) {
g.owner_name_original = g.name; // original name shown in brackets in GroupInfoModal
g.name = custom.name;
}
return g;
});
res.json({ publicGroups, privateGroups });
});
// Create group
router.post('/', authMiddleware, (req, res) => {
const { name, type, memberIds, isReadonly, isDirect } = req.body;
const db = getDb();
if (type === 'public' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only admins can create public groups' });
}
// Direct message: find or create
if (isDirect && memberIds && memberIds.length === 1) {
const otherUserId = memberIds[0];
const userId = req.user.id;
// Check if a direct group already exists between these two users
const existing = db.prepare(`
SELECT g.id FROM groups g
JOIN group_members gm1 ON gm1.group_id = g.id AND gm1.user_id = ?
JOIN group_members gm2 ON gm2.group_id = g.id AND gm2.user_id = ?
WHERE g.is_direct = 1
LIMIT 1
`).get(userId, otherUserId);
if (existing) {
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id);
// Ensure current user is still a member (may have left)
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(existing.id, userId);
// Re-set readonly to false so both can post again
db.prepare("UPDATE groups SET is_readonly = 0, owner_id = NULL, updated_at = datetime('now') WHERE id = ?").run(existing.id);
return res.json({ group: db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id) });
}
// Get other user's display name for the group name (stored internally, overridden per-user on fetch)
const otherUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(otherUserId);
const dmName = (otherUser?.display_name || otherUser?.name) + ' ↔ ' + (req.user.display_name || req.user.name);
const result = db.prepare(`
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, direct_peer1_id, direct_peer2_id)
VALUES (?, 'private', NULL, 0, 1, ?, ?)
`).run(dmName, userId, otherUserId);
const groupId = result.lastInsertRowid;
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, userId);
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, otherUserId);
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
// Notify both users via socket
emitGroupNew(io, groupId);
return res.json({ group });
}
// For private groups: check if exact same set of members already exists in a group
if ((type === 'private' || !type) && !isDirect && memberIds && memberIds.length > 0) {
const allMemberIds = [...new Set([req.user.id, ...memberIds])].sort((a, b) => a - b);
const count = allMemberIds.length;
// Find all private non-direct groups where the creator is a member
const candidates = db.prepare(`
SELECT g.id FROM groups g
JOIN group_members gm ON gm.group_id = g.id AND gm.user_id = ?
WHERE g.type = 'private' AND g.is_direct = 0
`).all(req.user.id);
for (const candidate of candidates) {
const members = db.prepare(
'SELECT user_id FROM group_members WHERE group_id = ? ORDER BY user_id'
).all(candidate.id).map(r => r.user_id);
if (members.length === count &&
members.every((id, i) => id === allMemberIds[i])) {
// Exact duplicate found — return the existing group
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(candidate.id);
return res.json({ group, duplicate: true });
}
}
}
const result = db.prepare(`
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct)
VALUES (?, ?, ?, ?, 0)
`).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0);
const groupId = result.lastInsertRowid;
if (type === 'public') {
const allUsers = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const u of allUsers) insert.run(groupId, u.id);
} else {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, req.user.id);
if (memberIds && memberIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const uid of memberIds) insert.run(groupId, uid);
}
}
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
// Notify all members via socket
emitGroupNew(io, groupId);
res.json({ group });
});
// Rename group
router.patch('/:id/rename', authMiddleware, (req, res) => {
const { name } = req.body;
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' });
if (group.is_direct) return res.status(403).json({ error: 'Cannot rename a direct message' });
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' });
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner can rename private group' });
}
db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name, group.id);
emitGroupUpdated(io, group.id);
res.json({ success: true });
});
// Get group members
router.get('/:id/members', authMiddleware, (req, res) => {
const db = getDb();
const members = db.prepare(`
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status
FROM group_members gm
JOIN users u ON gm.user_id = u.id
WHERE gm.group_id = ?
ORDER BY u.name ASC
`).all(req.params.id);
res.json({ members });
});
// Add member to private group
router.post('/:id/members', authMiddleware, (req, res) => {
const { userId } = req.body;
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' });
if (group.is_direct) return res.status(400).json({ error: 'Cannot add members to a direct message' });
if (group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner can add members' });
}
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(group.id, userId);
// Post a system message so all members see who was added
const addedUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
const sysResult = db.prepare(`
INSERT INTO messages (group_id, user_id, content, type)
VALUES (?, ?, ?, 'system')
`).run(group.id, userId, `${addedName} has joined the conversation.`);
const sysMsg = db.prepare(`
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(sysResult.lastInsertRowid);
sysMsg.reactions = [];
io.to(`group:${group.id}`).emit('message:new', sysMsg);
// Join all of the added user's active sockets to the group room server-side,
// so they receive messages immediately without needing a client round-trip
io.in(`user:${userId}`).socketsJoin(`group:${group.id}`);
// Notify the added user in real-time so their sidebar updates without a refresh
io.to(`user:${userId}`).emit('group:new', { group });
res.json({ success: true });
});
// Remove a member from a private group
router.delete('/:id/members/:userId', authMiddleware, (req, res) => {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot remove members from public groups' });
if (group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner or admin can remove members' });
}
const targetId = parseInt(req.params.userId);
if (targetId === group.owner_id) return res.status(400).json({ error: 'Cannot remove the group owner' });
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, targetId);
res.json({ success: true });
});
// Leave private group
router.delete('/:id/leave', authMiddleware, (req, res) => {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' });
const userId = req.user.id;
const leaverName = req.user.display_name || req.user.name;
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, userId);
// Post a system message so remaining members see the leave notice
const sysResult = db.prepare(`
INSERT INTO messages (group_id, user_id, content, type)
VALUES (?, ?, ?, 'system')
`).run(group.id, userId, `${leaverName} has left the conversation.`);
const sysMsg = db.prepare(`
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(sysResult.lastInsertRowid);
sysMsg.reactions = [];
// Broadcast to remaining members in the group room
io.to(`group:${group.id}`).emit('message:new', sysMsg);
if (group.is_direct) {
// Make remaining user owner so they can still manage the conversation
const remaining = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? LIMIT 1').get(group.id);
if (remaining) {
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?")
.run(remaining.user_id, group.id);
}
// Tell the leaver's socket to leave the group room and remove from sidebar
io.to(`user:${userId}`).emit('group:deleted', { groupId: group.id });
}
res.json({ success: true });
});
// Admin take ownership
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?").run(req.user.id, req.params.id);
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(req.params.id, req.user.id);
res.json({ success: true });
});
// Delete group
router.delete('/:id', authMiddleware, (req, res) => {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.is_default) return res.status(403).json({ error: 'Cannot delete default group' });
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can delete public groups' });
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner or admin can delete private groups' });
}
// Collect members before deleting
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(group.id).map(m => m.user_id);
// Add all active users for public groups
if (group.type === 'public') {
const all = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
all.forEach(u => { if (!members.includes(u.id)) members.push(u.id); });
}
// Collect all image files for this group before deleting
const imageMessages = db.prepare("SELECT image_url FROM messages WHERE group_id = ? AND image_url IS NOT NULL").all(group.id);
db.prepare('DELETE FROM groups WHERE id = ?').run(group.id);
// Delete image files from disk after DB delete
for (const msg of imageMessages) deleteImageFile(msg.image_url);
// Notify all affected users
emitGroupDeleted(io, group.id, members);
res.json({ success: true });
});
// Set or update user's custom name for a group
router.patch('/:id/custom-name', authMiddleware, (req, res) => {
const db = getDb();
const groupId = parseInt(req.params.id);
const userId = req.user.id;
const { name } = req.body;
if (!name || !name.trim()) {
// Empty name = remove custom name (revert to owner name)
db.prepare('DELETE FROM user_group_names WHERE user_id = ? AND group_id = ?').run(userId, groupId);
return res.json({ success: true, name: null });
}
db.prepare(`
INSERT INTO user_group_names (user_id, group_id, name)
VALUES (?, ?, ?)
ON CONFLICT(user_id, group_id) DO UPDATE SET name = excluded.name
`).run(userId, groupId, name.trim());
res.json({ success: true, name: name.trim() });
});
return router;
};

View File

@@ -0,0 +1,40 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const { getDb } = require('../models/db');
const { authMiddleware } = require('../middleware/auth');
// help.md lives inside the backend source tree — NOT in /app/data which is
// volume-mounted and would hide files baked into the image at build time.
const HELP_FILE = path.join(__dirname, '../data/help.md');
// GET /api/help — returns markdown content
router.get('/', authMiddleware, (req, res) => {
let content = '';
const filePath = HELP_FILE;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (e) {
content = '# Getting Started\n\nHelp content is not available yet.';
}
res.json({ content });
});
// GET /api/help/status — returns whether user has dismissed help
router.get('/status', authMiddleware, (req, res) => {
const db = getDb();
const user = db.prepare('SELECT help_dismissed FROM users WHERE id = ?').get(req.user.id);
res.json({ dismissed: !!user?.help_dismissed });
});
// POST /api/help/dismiss — set help_dismissed for current user
router.post('/dismiss', authMiddleware, (req, res) => {
const { dismissed } = req.body;
const db = getDb();
db.prepare("UPDATE users SET help_dismissed = ? WHERE id = ?")
.run(dismissed ? 1 : 0, req.user.id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,202 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { getDb } = require('../models/db');
// Delete an uploaded image file from disk if it lives under /app/uploads/images
function deleteImageFile(imageUrl) {
if (!imageUrl) return;
try {
const filePath = '/app' + imageUrl; // imageUrl is like /uploads/images/img_xxx.jpg
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch (e) {
console.warn('[Messages] Could not delete image file:', e.message);
}
}
module.exports = function(io) {
const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
const imgStorage = multer.diskStorage({
destination: '/app/uploads/images',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `img_${Date.now()}_${Math.random().toString(36).substr(2, 6)}${ext}`);
}
});
const uploadImage = multer({
storage: imgStorage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
});
function getUserForMessage(db, userId) {
return db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ?').get(userId);
}
function canAccessGroup(db, groupId, userId) {
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
if (!group) return null;
if (group.type === 'public') return group;
const member = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId);
if (!member) return null;
return group;
}
// Get messages for group
router.get('/group/:groupId', authMiddleware, (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
const { before, limit = 50 } = req.query;
let query = `
SELECT m.*,
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.status as user_status, u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me, u.allow_dm as user_allow_dm,
rm.content as reply_content, rm.image_url as reply_image_url,
ru.name as reply_user_name, ru.display_name as reply_user_display_name,
rm.is_deleted as reply_is_deleted
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.group_id = ?
`;
const params = [req.params.groupId];
if (before) {
query += ' AND m.id < ?';
params.push(before);
}
query += ' ORDER BY m.created_at DESC LIMIT ?';
params.push(parseInt(limit));
const messages = db.prepare(query).all(...params);
// Get reactions for these messages
for (const msg of messages) {
msg.reactions = db.prepare(`
SELECT r.emoji, r.user_id, u.name as user_name
FROM reactions r JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(msg.id);
}
res.json({ messages: messages.reverse() });
});
// Send message
router.post('/group/:groupId', authMiddleware, (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is read-only' });
const { content, replyToId, linkPreview } = req.body;
if (!content?.trim() && !req.body.imageUrl) return res.status(400).json({ error: 'Message cannot be empty' });
const result = db.prepare(`
INSERT INTO messages (group_id, user_id, content, reply_to_id, link_preview)
VALUES (?, ?, ?, ?, ?)
`).run(req.params.groupId, req.user.id, content?.trim() || null, replyToId || null, linkPreview ? JSON.stringify(linkPreview) : null);
const message = db.prepare(`
SELECT m.*,
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.allow_dm as user_allow_dm,
rm.content as reply_content, ru.name as reply_user_name, ru.display_name as reply_user_display_name
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
message.reactions = [];
io.to(`group:${req.params.groupId}`).emit('message:new', message);
res.json({ message });
});
// Upload image message
router.post('/group/:groupId/image', authMiddleware, uploadImage.single('image'), (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
if (!req.file) return res.status(400).json({ error: 'No image' });
const imageUrl = `/uploads/images/${req.file.filename}`;
const { content, replyToId } = req.body;
const result = db.prepare(`
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id)
VALUES (?, ?, ?, ?, 'image', ?)
`).run(req.params.groupId, req.user.id, content || null, imageUrl, replyToId || null);
const message = db.prepare(`
SELECT m.*,
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.allow_dm as user_allow_dm
FROM messages m JOIN users u ON m.user_id = u.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
message.reactions = [];
io.to(`group:${req.params.groupId}`).emit('message:new', message);
res.json({ message });
});
// Delete message
router.delete('/:id', authMiddleware, (req, res) => {
const db = getDb();
const message = db.prepare('SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_readonly FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?').get(req.params.id);
if (!message) return res.status(404).json({ error: 'Message not found' });
const canDelete = message.user_id === req.user.id ||
req.user.role === 'admin' ||
(message.group_type === 'private' && message.group_owner_id === req.user.id);
if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' });
const imageUrl = message.image_url;
db.prepare("UPDATE messages SET is_deleted = 1, content = null, image_url = null WHERE id = ?").run(message.id);
deleteImageFile(imageUrl);
io.to(`group:${message.group_id}`).emit('message:deleted', { messageId: message.id, groupId: message.group_id });
res.json({ success: true, messageId: message.id });
});
// Add/toggle reaction
router.post('/:id/reactions', authMiddleware, (req, res) => {
const { emoji } = req.body;
const db = getDb();
const message = db.prepare('SELECT * FROM messages WHERE id = ? AND is_deleted = 0').get(req.params.id);
if (!message) return res.status(404).json({ error: 'Message not found' });
// Check if user's message is from deleted/suspended user
const msgUser = db.prepare('SELECT status FROM users WHERE id = ?').get(message.user_id);
if (msgUser.status !== 'active') return res.status(400).json({ error: 'Cannot react to this message' });
const existing = db.prepare('SELECT * FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(message.id, req.user.id, emoji);
if (existing) {
db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id);
} else {
db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(message.id, req.user.id, emoji);
}
const reactions = db.prepare(`
SELECT r.emoji, r.user_id, u.name as user_name
FROM reactions r JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(message.id);
io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId: message.id, reactions });
res.json({ reactions });
});
return router;
};

104
backend/src/routes/push.js Normal file
View File

@@ -0,0 +1,104 @@
const express = require('express');
const webpush = require('web-push');
const router = express.Router();
const { getDb } = require('../models/db');
const { authMiddleware } = require('../middleware/auth');
// Get or generate VAPID keys stored in settings
function getVapidKeys() {
const db = getDb();
let pub = db.prepare("SELECT value FROM settings WHERE key = 'vapid_public'").get();
let priv = db.prepare("SELECT value FROM settings WHERE key = 'vapid_private'").get();
if (!pub?.value || !priv?.value) {
const keys = webpush.generateVAPIDKeys();
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
ins.run('vapid_public', keys.publicKey, keys.publicKey);
ins.run('vapid_private', keys.privateKey, keys.privateKey);
console.log('[Push] Generated new VAPID keys');
return keys;
}
return { publicKey: pub.value, privateKey: priv.value };
}
function initWebPush() {
const keys = getVapidKeys();
webpush.setVapidDetails(
'mailto:admin@jama.local',
keys.publicKey,
keys.privateKey
);
return keys.publicKey;
}
// Export for use in index.js
let vapidPublicKey = null;
function getVapidPublicKey() {
if (!vapidPublicKey) vapidPublicKey = initWebPush();
return vapidPublicKey;
}
// Send a push notification to all subscriptions for a user
async function sendPushToUser(userId, payload) {
const db = getDb();
getVapidPublicKey(); // ensure webpush is configured
const subs = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
for (const sub of subs) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload)
);
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
// Subscription expired — remove it
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
}
}
}
}
// GET /api/push/vapid-public — returns VAPID public key for client subscription
router.get('/vapid-public', (req, res) => {
res.json({ publicKey: getVapidPublicKey() });
});
// POST /api/push/subscribe — save push subscription for current user
router.post('/subscribe', authMiddleware, (req, res) => {
const { endpoint, keys } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return res.status(400).json({ error: 'Invalid subscription' });
}
const db = getDb();
const device = req.device || 'desktop';
// Delete any existing subscription for this user+device or this endpoint, then insert fresh
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ? OR (user_id = ? AND device = ?)').run(endpoint, req.user.id, device);
db.prepare('INSERT INTO push_subscriptions (user_id, device, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?)').run(req.user.id, device, endpoint, keys.p256dh, keys.auth);
res.json({ success: true });
});
// POST /api/push/generate-vapid — admin: generate (or regenerate) VAPID keys
router.post('/generate-vapid', authMiddleware, (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
const db = getDb();
const keys = webpush.generateVAPIDKeys();
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
ins.run('vapid_public', keys.publicKey, keys.publicKey);
ins.run('vapid_private', keys.privateKey, keys.privateKey);
// Reinitialise webpush with new keys immediately
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
vapidPublicKey = keys.publicKey;
console.log('[Push] VAPID keys regenerated by admin');
res.json({ publicKey: keys.publicKey });
});
// POST /api/push/unsubscribe — remove subscription
router.post('/unsubscribe', authMiddleware, (req, res) => {
const { endpoint } = req.body;
if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
const db = getDb();
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(req.user.id, endpoint);
res.json({ success: true });
});
module.exports = { router, sendPushToUser, getVapidPublicKey };

View File

@@ -0,0 +1,137 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const router = express.Router();
const { getDb } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
// Generic icon storage factory
function makeIconStorage(prefix) {
return multer.diskStorage({
destination: '/app/uploads/logos',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${prefix}_${Date.now()}${ext}`);
}
});
}
const iconUploadOpts = {
limits: { fileSize: 1 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
};
const uploadLogo = multer({ storage: makeIconStorage('logo'), ...iconUploadOpts });
const uploadNewChat = multer({ storage: makeIconStorage('newchat'), ...iconUploadOpts });
const uploadGroupInfo = multer({ storage: makeIconStorage('groupinfo'), ...iconUploadOpts });
// Get public settings (accessible by all)
router.get('/', (req, res) => {
const db = getDb();
const settings = db.prepare('SELECT key, value FROM settings').all();
const obj = {};
for (const s of settings) obj[s.key] = s.value;
const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
if (admin) obj.admin_email = admin.email;
// Expose app version from Docker build arg env var
obj.app_version = process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev';
obj.user_pass = process.env.USER_PASS || 'user@1234';
res.json({ settings: obj });
});
// Update app name (admin)
router.patch('/app-name', authMiddleware, adminMiddleware, (req, res) => {
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(name.trim());
res.json({ success: true, name: name.trim() });
});
// Upload app logo (admin) — also generates 192x192 and 512x512 PWA icons
router.post('/logo', authMiddleware, adminMiddleware, uploadLogo.single('logo'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const logoUrl = `/uploads/logos/${req.file.filename}`;
const srcPath = req.file.path;
try {
// Generate PWA icons from the uploaded logo
const icon192Path = '/app/uploads/logos/pwa-icon-192.png';
const icon512Path = '/app/uploads/logos/pwa-icon-512.png';
await sharp(srcPath)
.resize(192, 192, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(icon192Path);
await sharp(srcPath)
.resize(512, 512, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(icon512Path);
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
// Store the PWA icon paths so the manifest can reference them
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_192', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run('/uploads/logos/pwa-icon-192.png', '/uploads/logos/pwa-icon-192.png');
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_512', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run('/uploads/logos/pwa-icon-512.png', '/uploads/logos/pwa-icon-512.png');
res.json({ logoUrl });
} catch (err) {
console.error('[Logo] Failed to generate PWA icons:', err.message);
// Still save the logo even if icon generation fails
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
res.json({ logoUrl });
}
});
// Upload New Chat icon (admin)
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const iconUrl = `/uploads/logos/${req.file.filename}`;
const db = getDb();
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_newchat', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run(iconUrl, iconUrl);
res.json({ iconUrl });
});
// Upload Group Info icon (admin)
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const iconUrl = `/uploads/logos/${req.file.filename}`;
const db = getDb();
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_groupinfo', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run(iconUrl, iconUrl);
res.json({ iconUrl });
});
// Reset all settings to defaults (admin)
router.patch('/colors', authMiddleware, adminMiddleware, (req, res) => {
const { colorTitle, colorTitleDark, colorAvatarPublic, colorAvatarDm } = req.body;
const db = getDb();
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
if (colorTitle !== undefined) upd.run('color_title', colorTitle || '', colorTitle || '');
if (colorTitleDark !== undefined) upd.run('color_title_dark', colorTitleDark || '', colorTitleDark || '');
if (colorAvatarPublic !== undefined) upd.run('color_avatar_public', colorAvatarPublic || '', colorAvatarPublic || '');
if (colorAvatarDm !== undefined) upd.run('color_avatar_dm', colorAvatarDm || '', colorAvatarDm || '');
res.json({ success: true });
});
router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const originalName = process.env.APP_NAME || 'jama';
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(originalName);
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key = 'logo_url'").run();
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key IN ('icon_newchat', 'icon_groupinfo', 'pwa_icon_192', 'pwa_icon_512', 'color_title', 'color_title_dark', 'color_avatar_public', 'color_avatar_dm')").run();
res.json({ success: true });
});
module.exports = router;

320
backend/src/routes/users.js Normal file
View File

@@ -0,0 +1,320 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const multer = require('multer');
const path = require('path');
const router = express.Router();
const { getDb, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const avatarStorage = multer.diskStorage({
destination: '/app/uploads/avatars',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `avatar_${req.user.id}_${Date.now()}${ext}`);
}
});
const uploadAvatar = multer({
storage: avatarStorage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
});
// Resolve unique name: "John Doe" exists → return "John Doe (1)", then "(2)" etc.
function resolveUniqueName(db, baseName, excludeId = null) {
const existing = db.prepare(
"SELECT name FROM users WHERE status != 'deleted' AND id != ? AND (name = ? OR name LIKE ?)"
).all(excludeId ?? -1, baseName, `${baseName} (%)`);
if (existing.length === 0) return baseName;
let max = 0;
for (const u of existing) {
const m = u.name.match(/\((\d+)\)$/);
if (m) max = Math.max(max, parseInt(m[1]));
else max = Math.max(max, 0);
}
return `${baseName} (${max + 1})`;
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function getDefaultPassword(db) {
return process.env.USER_PASS || 'user@1234';
}
// List users (admin)
router.get('/', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const users = db.prepare(`
SELECT id, name, email, role, status, is_default_admin, must_change_password, avatar, about_me, display_name, allow_dm, created_at, last_online
FROM users WHERE status != 'deleted'
ORDER BY created_at ASC
`).all();
res.json({ users });
});
// Search users (public-ish for mentions/add-member)
router.get('/search', authMiddleware, (req, res) => {
const { q, groupId } = req.query;
const db = getDb();
let users;
if (groupId) {
const group = db.prepare('SELECT type, is_direct FROM groups WHERE id = ?').get(parseInt(groupId));
if (group && (group.type === 'private' || group.is_direct)) {
// Private group or direct message — only show members of this group
users = db.prepare(`
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status, u.hide_admin_tag, u.allow_dm
FROM users u
JOIN group_members gm ON gm.user_id = u.id AND gm.group_id = ?
WHERE u.status = 'active' AND u.id != ?
AND (u.name LIKE ? OR u.display_name LIKE ?)
LIMIT 10
`).all(parseInt(groupId), req.user.id, `%${q}%`, `%${q}%`);
} else {
// Public group — all active users
users = db.prepare(`
SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users
WHERE status = 'active' AND id != ? AND (name LIKE ? OR display_name LIKE ?)
LIMIT 10
`).all(req.user.id, `%${q}%`, `%${q}%`);
}
} else {
users = db.prepare(`
SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users
WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?)
LIMIT 10
`).all(`%${q}%`, `%${q}%`);
}
res.json({ users });
});
// Check if a display name is already taken (excludes self)
router.get('/check-display-name', authMiddleware, (req, res) => {
const { name } = req.query;
if (!name) return res.json({ taken: false });
const db = getDb();
const conflict = db.prepare(
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
).get(name, req.user.id);
res.json({ taken: !!conflict });
});
// Create user (admin) — req 3: skip duplicate email, req 4: suffix duplicate names
router.post('/', authMiddleware, adminMiddleware, (req, res) => {
const { name, email, password, role } = req.body;
if (!name || !email) return res.status(400).json({ error: 'Name and email required' });
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' });
const db = getDb();
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (exists) return res.status(400).json({ error: 'Email already in use' });
const resolvedName = resolveUniqueName(db, name.trim());
const pw = (password || '').trim() || getDefaultPassword(db);
const hash = bcrypt.hashSync(pw, 10);
const result = db.prepare(`
INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1)
`).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
addUserToPublicGroups(result.lastInsertRowid);
// Admin users are automatically added to the Support group
if (role === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, result.lastInsertRowid);
}
}
const user = db.prepare('SELECT id, name, email, role, status, must_change_password, created_at FROM users WHERE id = ?').get(result.lastInsertRowid);
res.json({ user });
});
// Bulk create users
router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
const { users } = req.body;
const db = getDb();
const results = { created: [], skipped: [] };
const seenEmails = new Set();
const defaultPw = getDefaultPassword(db);
const insertUser = db.prepare(`
INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1)
`);
for (const u of users) {
const email = (u.email || '').trim().toLowerCase();
const name = (u.name || '').trim();
if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; }
if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
seenEmails.add(email);
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
try {
const resolvedName = resolveUniqueName(db, name);
const pw = (u.password || '').trim() || defaultPw;
const hash = bcrypt.hashSync(pw, 10);
const newRole = u.role === 'admin' ? 'admin' : 'member';
const r = insertUser.run(resolvedName, email, hash, newRole);
addUserToPublicGroups(r.lastInsertRowid);
if (newRole === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, r.lastInsertRowid);
}
}
results.created.push(email);
} catch (e) {
results.skipped.push({ email, reason: e.message });
}
}
res.json(results);
});
// Update user name (admin only — req 5)
router.patch('/:id/name', authMiddleware, adminMiddleware, (req, res) => {
const { name } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
// Pass the target's own id so their current name is excluded from the duplicate check
const resolvedName = resolveUniqueName(db, name.trim(), req.params.id);
db.prepare("UPDATE users SET name = ?, updated_at = datetime('now') WHERE id = ?").run(resolvedName, target.id);
res.json({ success: true, name: resolvedName });
});
// Update user role (admin)
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
const { role } = req.body;
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
// If promoted to admin, ensure they're in the Support group
if (role === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, target.id);
}
}
res.json({ success: true });
});
// Reset user password (admin)
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, (req, res) => {
const { password } = req.body;
if (!password || password.length < 6) return res.status(400).json({ error: 'Password too short' });
const db = getDb();
const hash = bcrypt.hashSync(password, 10);
db.prepare("UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now') WHERE id = ?").run(hash, req.params.id);
res.json({ success: true });
});
// Suspend user (admin)
router.patch('/:id/suspend', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id);
res.json({ success: true });
});
// Activate user (admin)
router.patch('/:id/activate', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
db.prepare("UPDATE users SET status = 'active', updated_at = datetime('now') WHERE id = ?").run(req.params.id);
res.json({ success: true });
});
// Delete user (admin)
router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id);
res.json({ success: true });
});
// Update own profile — display name must be unique (req 6)
router.patch('/me/profile', authMiddleware, (req, res) => {
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
const db = getDb();
if (displayName) {
const conflict = db.prepare(
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
).get(displayName, req.user.id);
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
}
db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, allow_dm = ?, updated_at = datetime('now') WHERE id = ?")
.run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, allowDm === false ? 0 : 1, req.user.id);
const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag, allow_dm FROM users WHERE id = ?').get(req.user.id);
res.json({ user });
});
// Upload avatar — resize if needed, skip compression for files under 500 KB
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const sharp = require('sharp');
const filePath = req.file.path;
const fileSizeBytes = req.file.size;
const FIVE_HUNDRED_KB = 500 * 1024;
const MAX_DIM = 256; // max width/height in pixels
const image = sharp(filePath);
const meta = await image.metadata();
const needsResize = (meta.width > MAX_DIM || meta.height > MAX_DIM);
if (fileSizeBytes < FIVE_HUNDRED_KB && !needsResize) {
// Small enough and already correctly sized — serve as-is
} else {
// Resize (and compress only if over 500 KB)
const outPath = filePath.replace(/(\.[^.]+)$/, '_p$1');
let pipeline = sharp(filePath).resize(MAX_DIM, MAX_DIM, { fit: 'cover', withoutEnlargement: true });
if (fileSizeBytes >= FIVE_HUNDRED_KB) {
// Compress: use webp for best size/quality ratio
pipeline = pipeline.webp({ quality: 82 });
await pipeline.toFile(outPath + '.webp');
const fs = require('fs');
fs.unlinkSync(filePath);
fs.renameSync(outPath + '.webp', filePath.replace(/\.[^.]+$/, '.webp'));
const newPath = filePath.replace(/\.[^.]+$/, '.webp');
const newFilename = path.basename(newPath);
const db = getDb();
const avatarUrl = `/uploads/avatars/${newFilename}`;
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
return res.json({ avatarUrl });
} else {
// Under 500 KB but needs resize — resize only, keep original format
await pipeline.toFile(outPath);
const fs = require('fs');
fs.unlinkSync(filePath);
fs.renameSync(outPath, filePath);
}
}
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const db = getDb();
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
res.json({ avatarUrl });
} catch (err) {
console.error('Avatar processing error:', err);
// Fall back to serving unprocessed file
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const db = getDb();
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
res.json({ avatarUrl });
}
});
module.exports = router;

View File

@@ -0,0 +1,37 @@
const fetch = require('node-fetch');
async function getLinkPreview(url) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(url, {
signal: controller.signal,
headers: { 'User-Agent': 'JamaBot/1.0' }
});
clearTimeout(timeout);
const html = await res.text();
const getTag = (name) => {
const match = html.match(new RegExp(`<meta[^>]*property=["']${name}["'][^>]*content=["']([^"']+)["']`, 'i')) ||
html.match(new RegExp(`<meta[^>]*content=["']([^"']+)["'][^>]*property=["']${name}["']`, 'i')) ||
html.match(new RegExp(`<meta[^>]*name=["']${name}["'][^>]*content=["']([^"']+)["']`, 'i'));
return match?.[1] || '';
};
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
return {
url,
title: getTag('og:title') || titleMatch?.[1] || url,
description: getTag('og:description') || getTag('description') || '',
image: getTag('og:image') || '',
siteName: getTag('og:site_name') || new URL(url).hostname,
};
} catch (e) {
return null;
}
}
module.exports = { getLinkPreview };