version 0.0.24
This commit is contained in:
@@ -55,7 +55,7 @@ app.get('/manifest.json', (req, res) => {
|
||||
const s = {};
|
||||
for (const r of rows) s[r.key] = r.value;
|
||||
|
||||
const appName = s.app_name || process.env.APP_NAME || 'TeamChat';
|
||||
const appName = s.app_name || process.env.APP_NAME || 'jama';
|
||||
const pwa192 = s.pwa_icon_192 || '';
|
||||
const pwa512 = s.pwa_icon_512 || '';
|
||||
|
||||
@@ -104,7 +104,12 @@ io.use((socket, next) => {
|
||||
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'));
|
||||
@@ -305,5 +310,5 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`TeamChat server running on port ${PORT}`);
|
||||
console.log(`jama server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,16 @@ 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' });
|
||||
@@ -12,7 +22,16 @@ function authMiddleware(req, res, next) {
|
||||
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' });
|
||||
@@ -28,4 +47,27 @@ function generateToken(userId) {
|
||||
return jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '30d' });
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, adminMiddleware, generateToken };
|
||||
// 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 };
|
||||
|
||||
@@ -3,7 +3,7 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || '/app/data/teamchat.db';
|
||||
const DB_PATH = process.env.DB_PATH || '/app/data/jama.db';
|
||||
|
||||
let db;
|
||||
|
||||
@@ -118,11 +118,21 @@ function initDb() {
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
// Initialize default settings
|
||||
const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
||||
insertSetting.run('app_name', process.env.APP_NAME || 'TeamChat');
|
||||
insertSetting.run('app_name', process.env.APP_NAME || 'jama');
|
||||
insertSetting.run('logo_url', '');
|
||||
insertSetting.run('pw_reset_active', process.env.PW_RESET === 'true' ? 'true' : 'false');
|
||||
insertSetting.run('icon_newchat', '');
|
||||
@@ -136,6 +146,26 @@ function initDb() {
|
||||
console.log('[DB] Migration: added hide_admin_tag 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); }
|
||||
|
||||
console.log('[DB] Schema initialized');
|
||||
return db;
|
||||
}
|
||||
@@ -144,7 +174,7 @@ function seedAdmin() {
|
||||
const db = getDb();
|
||||
|
||||
// Strip any surrounding quotes from env vars (common docker-compose mistake)
|
||||
const adminEmail = (process.env.ADMIN_EMAIL || 'admin@teamchat.local').replace(/^["']|["']$/g, '').trim();
|
||||
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.PW_RESET === 'true';
|
||||
@@ -163,17 +193,17 @@ function seedAdmin() {
|
||||
|
||||
console.log(`[DB] Default admin created: ${adminEmail} (id=${result.lastInsertRowid})`);
|
||||
|
||||
// Create default TeamChat group
|
||||
// Create default public group
|
||||
const groupResult = db.prepare(`
|
||||
INSERT INTO groups (name, type, is_default, owner_id)
|
||||
VALUES ('TeamChat', 'public', 1, ?)
|
||||
`).run(result.lastInsertRowid);
|
||||
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 TeamChat group created');
|
||||
console.log(`[DB] Default group created: ${process.env.DEFCHAT_NAME || 'General Chat'}`);
|
||||
seedSupportGroup();
|
||||
} catch (err) {
|
||||
console.error('[DB] ERROR creating default admin:', err.message);
|
||||
|
||||
@@ -2,7 +2,7 @@ const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const router = express.Router();
|
||||
const { getDb, getOrCreateSupportGroup } = require('../models/db');
|
||||
const { generateToken, authMiddleware } = require('../middleware/auth');
|
||||
const { generateToken, authMiddleware, setActiveSession, clearActiveSession } = require('../middleware/auth');
|
||||
|
||||
// Login
|
||||
router.post('/login', (req, res) => {
|
||||
@@ -25,6 +25,8 @@ router.post('/login', (req, res) => {
|
||||
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
|
||||
|
||||
const { password: _, ...userSafe } = user;
|
||||
res.json({
|
||||
@@ -58,8 +60,9 @@ router.get('/me', authMiddleware, (req, res) => {
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
// Logout (client-side token removal, but we can track it)
|
||||
// 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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -116,6 +116,23 @@ router.post('/:id/members', authMiddleware, (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Remove a member from a private group (owner or admin only)
|
||||
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();
|
||||
|
||||
@@ -141,7 +141,7 @@ router.delete('/:id', authMiddleware, (req, res) => {
|
||||
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 === 'public') ||
|
||||
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' });
|
||||
|
||||
@@ -24,7 +24,7 @@ function getVapidKeys() {
|
||||
function initWebPush() {
|
||||
const keys = getVapidKeys();
|
||||
webpush.setVapidDetails(
|
||||
'mailto:admin@teamchat.local',
|
||||
'mailto:admin@jama.local',
|
||||
keys.publicKey,
|
||||
keys.privateKey
|
||||
);
|
||||
|
||||
@@ -115,7 +115,7 @@ router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.
|
||||
// Reset all settings to defaults (admin)
|
||||
router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const originalName = process.env.APP_NAME || 'TeamChat';
|
||||
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')").run();
|
||||
|
||||
@@ -7,7 +7,7 @@ async function getLinkPreview(url) {
|
||||
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'TeamChatBot/1.0' }
|
||||
headers: { 'User-Agent': 'JamaBot/1.0' }
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user