Initial Push to GIT
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal file
@@ -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
|
||||||
44
Dockerfile
Normal file
44
Dockerfile
Normal file
@@ -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"]
|
||||||
221
README.md
221
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`.
|
||||||
|
|||||||
27
backend/package.json
Normal file
27
backend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
309
backend/src/index.js
Normal file
309
backend/src/index.js
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
31
backend/src/middleware/auth.js
Normal file
31
backend/src/middleware/auth.js
Normal file
@@ -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 };
|
||||||
242
backend/src/models/db.js
Normal file
242
backend/src/models/db.js
Normal file
@@ -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 };
|
||||||
102
backend/src/routes/auth.js
Normal file
102
backend/src/routes/auth.js
Normal file
@@ -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;
|
||||||
153
backend/src/routes/groups.js
Normal file
153
backend/src/routes/groups.js
Normal file
@@ -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;
|
||||||
175
backend/src/routes/messages.js
Normal file
175
backend/src/routes/messages.js
Normal file
@@ -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;
|
||||||
90
backend/src/routes/push.js
Normal file
90
backend/src/routes/push.js
Normal file
@@ -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 };
|
||||||
125
backend/src/routes/settings.js
Normal file
125
backend/src/routes/settings.js
Normal file
@@ -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;
|
||||||
177
backend/src/routes/users.js
Normal file
177
backend/src/routes/users.js
Normal file
@@ -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;
|
||||||
37
backend/src/utils/linkPreview.js
Normal file
37
backend/src/utils/linkPreview.js
Normal 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': 'TeamChatBot/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 };
|
||||||
74
build.sh
Normal file
74
build.sh
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# TeamChat — Docker build & release script
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build.sh # builds teamchat:latest
|
||||||
|
# ./build.sh 1.2.0 # builds teamchat:1.2.0 AND teamchat:latest
|
||||||
|
# ./build.sh 1.2.0 push # builds, tags, and pushes to registry
|
||||||
|
#
|
||||||
|
# To push to a registry, set REGISTRY env var:
|
||||||
|
# REGISTRY=ghcr.io/yourname ./build.sh 1.2.0 push
|
||||||
|
# REGISTRY=yourdockerhubuser ./build.sh 1.2.0 push
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${1:-latest}"
|
||||||
|
ACTION="${2:-}"
|
||||||
|
REGISTRY="${REGISTRY:-}"
|
||||||
|
IMAGE_NAME="teamchat"
|
||||||
|
|
||||||
|
# If a registry is set, prefix image name
|
||||||
|
if [[ -n "$REGISTRY" ]]; then
|
||||||
|
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||||
|
else
|
||||||
|
FULL_IMAGE="${IMAGE_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "╔══════════════════════════════════════╗"
|
||||||
|
echo "║ TeamChat Docker Builder ║"
|
||||||
|
echo "╠══════════════════════════════════════╣"
|
||||||
|
echo "║ Image : ${FULL_IMAGE}"
|
||||||
|
echo "║ Version : ${VERSION}"
|
||||||
|
echo "╚══════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Build — npm install runs inside Docker, no host npm required
|
||||||
|
echo "▶ Building image..."
|
||||||
|
docker build \
|
||||||
|
--build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
--build-arg VERSION="${VERSION}" \
|
||||||
|
-t "${FULL_IMAGE}:${VERSION}" \
|
||||||
|
-t "${FULL_IMAGE}:latest" \
|
||||||
|
-f Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✔ Built successfully:"
|
||||||
|
echo " ${FULL_IMAGE}:${VERSION}"
|
||||||
|
echo " ${FULL_IMAGE}:latest"
|
||||||
|
|
||||||
|
# Optionally push
|
||||||
|
if [[ "$ACTION" == "push" ]]; then
|
||||||
|
if [[ -z "$REGISTRY" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "⚠ No REGISTRY set. Pushing to Docker Hub as '${IMAGE_NAME}'."
|
||||||
|
echo " Set REGISTRY=youruser or REGISTRY=ghcr.io/yourorg to override."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "▶ Pushing ${FULL_IMAGE}:${VERSION}..."
|
||||||
|
docker push "${FULL_IMAGE}:${VERSION}"
|
||||||
|
echo "▶ Pushing ${FULL_IMAGE}:latest..."
|
||||||
|
docker push "${FULL_IMAGE}:latest"
|
||||||
|
echo ""
|
||||||
|
echo "✔ Pushed successfully."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "─────────────────────────────────────────"
|
||||||
|
echo "To deploy this version, set in your .env:"
|
||||||
|
echo " TEAMCHAT_VERSION=${VERSION}"
|
||||||
|
echo ""
|
||||||
|
echo "Then run:"
|
||||||
|
echo " docker compose up -d"
|
||||||
|
echo "─────────────────────────────────────────"
|
||||||
31
docker-compose.yaml
Normal file
31
docker-compose.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
teamchat:
|
||||||
|
image: teamchat:${TEAMCHAT_VERSION:-latest}
|
||||||
|
container_name: teamchat
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
||||||
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@teamchat.local}
|
||||||
|
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
||||||
|
- PW_RESET=${PW_RESET:-false}
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
||||||
|
- APP_NAME=${APP_NAME:-TeamChat}
|
||||||
|
volumes:
|
||||||
|
- teamchat_db:/app/data
|
||||||
|
- teamchat_uploads:/app/uploads
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
teamchat_db:
|
||||||
|
driver: local
|
||||||
|
teamchat_uploads:
|
||||||
|
driver: local
|
||||||
Reference in New Issue
Block a user