diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d6d3db1 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# TeamChat Configuration +# Copy this file to .env and customize + +# Image version to run (set by build.sh, or use 'latest') +TEAMCHAT_VERSION=latest + +# Default admin credentials (used on FIRST RUN only) +ADMIN_NAME=Admin User +ADMIN_EMAIL=admin@teamchat.local +ADMIN_PASS=Admin@1234 + +# Set to true to reset admin password to ADMIN_PASS on every restart +# WARNING: Leave false in production - shows a warning on login page when true +PW_RESET=false + +# JWT secret - change this to a random string in production! +JWT_SECRET=changeme_super_secret_jwt_key_change_in_production + +# App port (default 3000) +PORT=3000 + +# App name (can also be changed in Settings UI) +APP_NAME=TeamChat diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3d3b6b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +ARG VERSION=dev +ARG BUILD_DATE=unknown + +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install frontend dependencies and build +COPY frontend/package*.json ./frontend/ +RUN cd frontend && npm install + +COPY frontend/ ./frontend/ +RUN cd frontend && npm run build + +# Backend +FROM node:20-alpine + +ARG VERSION=dev +ARG BUILD_DATE=unknown + +LABEL org.opencontainers.image.title="TeamChat" \ + org.opencontainers.image.description="Self-hosted team chat PWA" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.source="https://github.com/yourorg/teamchat" + +ENV TEAMCHAT_VERSION=${VERSION} + +RUN apk add --no-cache sqlite + +WORKDIR /app + +COPY backend/package*.json ./ +RUN npm install --omit=dev + +COPY backend/ ./ +COPY --from=builder /app/frontend/dist ./public + +# Create data and uploads directories +RUN mkdir -p /app/data /app/uploads/avatars /app/uploads/logos /app/uploads/images + +EXPOSE 3000 + +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md index 297c53a..e3100bd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,221 @@ -# teamchat +# TeamChat π¬ +A modern, self-hosted team chat Progressive Web App (PWA) β similar to Google Messages / Facebook Messenger for teams. + +--- + +## Features + +- π **Authentication** β Login, remember me, forced password change on first login +- π¬ **Real-time messaging** β WebSocket (Socket.io) powered chat +- π₯ **Public channels** β Admin-created, all users auto-joined +- π **Private groups** β User-created, owner-managed +- π· **Image uploads** β Attach images to messages +- π¬ **Message quoting** β Reply to any message with preview +- π **Emoji reactions** β Quick reactions + full emoji picker +- @**Mentions** β @mention users with autocomplete, they get notified +- π **Link previews** β Auto-fetches OG metadata for URLs +- π± **PWA** β Install to home screen, works offline +- π€ **Profiles** β Custom avatars, display names, about me +- βοΈ **Admin settings** β Custom logo, app name +- π¨βπΌ **User management** β Create, suspend, delete, bulk CSV import +- π’ **Read-only channels** β Announcement-style public channels + +--- + +## Quick Start + +### Prerequisites +- Docker & Docker Compose + +### 1. Build a versioned image + +```bash +# Build and tag as v1.0.0 (also tags :latest) +./build.sh 1.0.0 + +# Build latest only +./build.sh +``` + +### 2. Deploy with Docker Compose + +```bash +cp .env.example .env +# Edit .env β set TEAMCHAT_VERSION, admin credentials, JWT_SECRET +nano .env + +docker compose up -d + +# View logs +docker compose logs -f +``` + +App will be available at **http://localhost:3000** + +--- + +## Release Workflow + +TeamChat uses a **build-then-run** pattern. You build the image once on your build machine (or CI), then the compose file just runs the pre-built image β no build step at deploy time. + +``` +βββββββββββββββββββββββ ββββββββββββββββββββββββββββ +β Build machine / CI β β Server / Portainer β +β β β β +β ./build.sh 1.2.0 βββββββΆβ TEAMCHAT_VERSION=1.2.0 β +β (or push to β β docker compose up -d β +β registry first) β β β +βββββββββββββββββββββββ ββββββββββββββββββββββββββββ +``` + +### Build script usage + +```bash +# Build locally (image stays on this machine) +./build.sh 1.0.0 + +# Build and push to Docker Hub +REGISTRY=yourdockerhubuser ./build.sh 1.0.0 push + +# Build and push to GHCR +REGISTRY=ghcr.io/yourorg ./build.sh 1.0.0 push +``` + +### Deploying a specific version + +Set `TEAMCHAT_VERSION` in your `.env` before running compose: + +```bash +# .env +TEAMCHAT_VERSION=1.2.0 +``` + +```bash +docker compose pull # if pulling from a registry +docker compose up -d +``` + +### Rolling back + +```bash +# .env +TEAMCHAT_VERSION=1.1.0 + +docker compose up -d # instantly rolls back to previous image +``` + +Data volumes are unaffected by version changes. + +--- + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ADMIN_NAME` | `Admin User` | Default admin display name | +| `ADMIN_EMAIL` | `admin@teamchat.local` | Default admin email (login) | +| `ADMIN_PASS` | `Admin@1234` | Default admin password (first run only) | +| `PW_RESET` | `false` | If `true`, resets admin password to `ADMIN_PASS` on every restart | +| `JWT_SECRET` | *(insecure default)* | **Change this!** Used to sign auth tokens | +| `PORT` | `3000` | HTTP port to listen on | +| `APP_NAME` | `TeamChat` | Initial app name (can be changed in Settings) | + +> **Important:** `ADMIN_EMAIL` and `ADMIN_PASS` are only used on the very first run to create the admin account. After the admin changes their password, these variables are ignored β **unless** `PW_RESET=true`. + +--- + +## First Login + +1. Navigate to `http://localhost:3000` +2. Login with `ADMIN_EMAIL` / `ADMIN_PASS` +3. You'll be prompted to **change your password** immediately +4. You're in! The default **TeamChat** public channel is ready + +--- + +## PW_RESET Warning + +If you set `PW_RESET=true`: +- The admin password resets to `ADMIN_PASS` on **every container restart** +- A β οΈ warning banner appears on the login page +- This is intentional for emergency access recovery +- **Always set back to `false` after recovering access** + +--- + +## User Management + +Admins can access **User Manager** from the bottom menu: + +- **Create single user** β Name, email, temp password, role +- **Bulk import via CSV** β Format: `name,email,password,role` +- **Reset password** β User is forced to change on next login +- **Suspend / Activate** β Suspended users cannot login +- **Delete** β Soft delete; messages remain, sessions invalidated +- **Elevate / Demote** β Change member β admin role + +--- + +## Group Types + +| | Public Channels | Private Groups | +|--|--|--| +| Creator | Admin only | Any user | +| Members | All users (auto) | Invited by owner | +| Visible to admins | β Yes | β No (unless admin takes ownership) | +| Leave | β Not allowed | β Yes | +| Rename | Admin only | Owner only | +| Read-only mode | β Optional | β N/A | +| Default group | TeamChat (permanent) | β | + +--- + +## CSV Import Format + +```csv +name,email,password,role +John Doe,john@example.com,TempPass123,member +Jane Admin,jane@example.com,Admin@456,admin +``` + +- `role` can be `member` or `admin` +- `password` defaults to `TempPass@123` if omitted +- All imported users must change password on first login + +--- + +## Data Persistence + +All data is stored in Docker volumes: +- `teamchat_db` β SQLite database +- `teamchat_uploads` β User avatars, logos, message images + +Data survives container restarts and redeployments. + +--- + +## PWA Installation + +On mobile: **Share β Add to Home Screen** +On desktop (Chrome): Click the install icon in the address bar + +--- + +## Portainer / Dockhand Deployment + +Use the `docker-compose.yaml` directly in Portainer's Stack editor. Set environment variables in the `.env` section or directly in the compose file. + +--- + +## Development + +```bash +# Backend +cd backend && npm install && npm run dev + +# Frontend (in another terminal) +cd frontend && npm install && npm run dev +``` + +Frontend dev server proxies API calls to `localhost:3000`. diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..7d59524 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,27 @@ +{ + "name": "teamchat-backend", + "version": "1.0.0", + "description": "TeamChat backend server", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^9.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" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..c0549f4 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,309 @@ +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(); +getOrCreateSupportGroup(); // Ensure Support group exists + +// 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')); +app.use('/api/users', require('./routes/users')); +app.use('/api/groups', require('./routes/groups')); +app.use('/api/messages', require('./routes/messages')); +app.use('/api/settings', require('./routes/settings')); +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 || 'TeamChat'; + 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')); + socket.user = user; + 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); + + // Broadcast online status + io.emit('user:online', { 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}`); + + // 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: '/', + 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 + if (content) { + const mentions = content.match(/@\[([^\]]+)\]\((\d+)\)/g) || []; + for (const mention of mentions) { + const matchId = mention.match(/\((\d+)\)/)?.[1]; + 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(/@\[[^\]]+\]\(\d+\)/g, (m) => '@' + m.match(/\[([^\]]+)\]/)?.[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 FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?').get(messageId); + if (!message) return; + + const canDelete = message.user_id === userId || + (socket.user.role === 'admin' && message.group_type === 'public') || + (message.group_type === 'private' && message.group_owner_id === userId); + + 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); + io.emit('user:offline', { userId }); + } + } + }); +}); + +server.listen(PORT, () => { + console.log(`TeamChat server running on port ${PORT}`); +}); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..bddb8c1 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,31 @@ +const jwt = require('jsonwebtoken'); +const { getDb } = require('../models/db'); + +const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret'; + +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' }); + req.user = user; + 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' }); +} + +module.exports = { authMiddleware, adminMiddleware, generateToken }; diff --git a/backend/src/models/db.js b/backend/src/models/db.js new file mode 100644 index 0000000..3618289 --- /dev/null +++ b/backend/src/models/db.js @@ -0,0 +1,242 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); +const bcrypt = require('bcryptjs'); + +const DB_PATH = process.env.DB_PATH || '/app/data/teamchat.db'; + +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); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + console.log(`[DB] Opened database at ${DB_PATH}`); + } + 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, + 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')) + ); + `); + + // 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('logo_url', ''); + insertSetting.run('pw_reset_active', process.env.PW_RESET === 'true' ? 'true' : 'false'); + insertSetting.run('icon_newchat', ''); + insertSetting.run('icon_groupinfo', ''); + insertSetting.run('pwa_icon_192', ''); + insertSetting.run('pwa_icon_512', ''); + + // 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 */ } + + 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@teamchat.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'; + + 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 TeamChat group + const groupResult = db.prepare(` + INSERT INTO groups (name, type, is_default, owner_id) + VALUES ('TeamChat', 'public', 1, ?) + `).run(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'); + seedSupportGroup(); + } catch (err) { + console.error('[DB] ERROR creating default admin:', err.message); + } + return; + } + + console.log(`[DB] Default admin already exists (id=${existing.id})`); + + // Handle PW_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 PW_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 }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..2707130 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,102 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const router = express.Router(); +const { getDb, getOrCreateSupportGroup } = require('../models/db'); +const { generateToken, authMiddleware } = require('../middleware/auth'); + +// 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 { 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 (client-side token removal, but we can track it) +router.post('/logout', authMiddleware, (req, res) => { + 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()}`; + + db.prepare(` + INSERT INTO messages (group_id, user_id, content, type) + VALUES (?, ?, ?, 'text') + `).run(groupId, admin.id, content); + + console.log(`[Support] Message from ${email} posted to Support group`); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js new file mode 100644 index 0000000..7634d58 --- /dev/null +++ b/backend/src/routes/groups.js @@ -0,0 +1,153 @@ +const express = require('express'); +const router = express.Router(); +const { getDb } = require('../models/db'); +const { authMiddleware, adminMiddleware } = require('../middleware/auth'); + +// Get all groups for current user +router.get('/', authMiddleware, (req, res) => { + const db = getDb(); + const userId = req.user.id; + + // Public groups (all users are members) + 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 + FROM groups g + WHERE g.type = 'public' + ORDER BY g.is_default DESC, g.name ASC + `).all(); + + // Private groups (user is a member) + const privateGroups = 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 + 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); + + res.json({ publicGroups, privateGroups }); +}); + +// Create group +router.post('/', authMiddleware, (req, res) => { + const { name, type, memberIds, isReadonly } = req.body; + const db = getDb(); + + if (type === 'public' && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Only admins can create public groups' }); + } + + const result = db.prepare(` + INSERT INTO groups (name, type, owner_id, is_readonly) + VALUES (?, ?, ?, ?) + `).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0); + + const groupId = result.lastInsertRowid; + + if (type === 'public') { + // Add all users to public group + 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 { + // Add creator + db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, req.user.id); + // Add other members + 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); + 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.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); + 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.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); + 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' }); + + db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, req.user.id); + res.json({ success: true }); +}); + +// Admin take ownership of private group +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 (admin or private group owner) +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' }); + } + + db.prepare('DELETE FROM groups WHERE id = ?').run(group.id); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js new file mode 100644 index 0000000..6f0d92c --- /dev/null +++ b/backend/src/routes/messages.js @@ -0,0 +1,175 @@ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const router = express.Router(); +const { getDb } = require('../models/db'); +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, + 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, + 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 = []; + 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 + FROM messages m JOIN users u ON m.user_id = u.id + WHERE m.id = ? + `).get(result.lastInsertRowid); + + message.reactions = []; + 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 === 'public') || + (message.group_type === 'private' && message.group_owner_id === req.user.id); + + if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' }); + + db.prepare("UPDATE messages SET is_deleted = 1, content = null, image_url = null WHERE id = ?").run(message.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); + res.json({ removed: true, emoji }); + } else { + db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(message.id, req.user.id, emoji); + res.json({ added: true, emoji }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/push.js b/backend/src/routes/push.js new file mode 100644 index 0000000..a1bb0f9 --- /dev/null +++ b/backend/src/routes/push.js @@ -0,0 +1,90 @@ +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@teamchat.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(); + db.prepare(` + INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth) + VALUES (?, ?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET user_id = ?, p256dh = ?, auth = ? + `).run(req.user.id, endpoint, keys.p256dh, keys.auth, req.user.id, keys.p256dh, keys.auth); + res.json({ success: true }); +}); + +// 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 }; diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js new file mode 100644 index 0000000..86704d6 --- /dev/null +++ b/backend/src/routes/settings.js @@ -0,0 +1,125 @@ +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.TEAMCHAT_VERSION || 'dev'; + 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.post('/reset', authMiddleware, adminMiddleware, (req, res) => { + const db = getDb(); + const originalName = process.env.APP_NAME || 'TeamChat'; + 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(); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js new file mode 100644 index 0000000..6dac2d6 --- /dev/null +++ b/backend/src/routes/users.js @@ -0,0 +1,177 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const router = express.Router(); +const { getDb, addUserToPublicGroups } = 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')); + } +}); + +// 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, created_at + FROM users WHERE status != 'deleted' + ORDER BY created_at ASC + `).all(); + res.json({ users }); +}); + +// Get single user profile (public-ish for mentions) +router.get('/search', authMiddleware, (req, res) => { + const { q } = req.query; + const db = getDb(); + const users = db.prepare(` + SELECT id, name, display_name, avatar, role, status, hide_admin_tag FROM users + WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?) + LIMIT 10 + `).all(`%${q}%`, `%${q}%`); + res.json({ users }); +}); + +// Create user (admin) +router.post('/', authMiddleware, adminMiddleware, (req, res) => { + const { name, email, password, role } = req.body; + if (!name || !email || !password) return res.status(400).json({ error: 'Name, email, password required' }); + + 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 hash = bcrypt.hashSync(password, 10); + const result = db.prepare(` + INSERT INTO users (name, email, password, role, status, must_change_password) + VALUES (?, ?, ?, ?, 'active', 1) + `).run(name, email, hash, role === 'admin' ? 'admin' : 'member'); + + addUserToPublicGroups(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 via CSV data +router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => { + const { users } = req.body; // array of {name, email, password, role} + const db = getDb(); + const results = { created: [], errors: [] }; + + const insertUser = db.prepare(` + INSERT INTO users (name, email, password, role, status, must_change_password) + VALUES (?, ?, ?, ?, 'active', 1) + `); + + const transaction = db.transaction((users) => { + for (const u of users) { + if (!u.name || !u.email || !u.password) { + results.errors.push({ email: u.email, error: 'Missing required fields' }); + continue; + } + const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(u.email); + if (exists) { + results.errors.push({ email: u.email, error: 'Email already exists' }); + continue; + } + try { + const hash = bcrypt.hashSync(u.password, 10); + const r = insertUser.run(u.name, u.email, hash, u.role === 'admin' ? 'admin' : 'member'); + addUserToPublicGroups(r.lastInsertRowid); + results.created.push(u.email); + } catch (e) { + results.errors.push({ email: u.email, error: e.message }); + } + } + }); + + transaction(users); + res.json(results); +}); + +// 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); + 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 +router.patch('/me/profile', authMiddleware, (req, res) => { + const { displayName, aboutMe, hideAdminTag } = req.body; + const db = getDb(); + db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, updated_at = datetime('now') WHERE id = ?") + .run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, req.user.id); + const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag FROM users WHERE id = ?').get(req.user.id); + res.json({ user }); +}); + +// Upload avatar +router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + 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; diff --git a/backend/src/utils/linkPreview.js b/backend/src/utils/linkPreview.js new file mode 100644 index 0000000..b2ce194 --- /dev/null +++ b/backend/src/utils/linkPreview.js @@ -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': 'TeamChatBot/1.0' } + }); + clearTimeout(timeout); + + const html = await res.text(); + + const getTag = (name) => { + const match = html.match(new RegExp(`]*property=["']${name}["'][^>]*content=["']([^"']+)["']`, 'i')) || + html.match(new RegExp(`]*content=["']([^"']+)["'][^>]*property=["']${name}["']`, 'i')) || + html.match(new RegExp(`]*name=["']${name}["'][^>]*content=["']([^"']+)["']`, 'i')); + return match?.[1] || ''; + }; + + const titleMatch = html.match(/