feat: initial JaniChat messenger — PWA, WebSocket, admin panel

This commit is contained in:
Ai
2026-05-22 11:06:43 +03:00
commit 1cabe9d04f
46 changed files with 3570 additions and 0 deletions

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

29
backend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "janichat-api",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.1",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"@fastify/websocket": "^8.3.1",
"bcryptjs": "^2.4.3",
"fastify": "^4.28.1",
"pg": "^8.12.0",
"web-push": "^3.6.7"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.0",
"@types/pg": "^8.11.6",
"@types/web-push": "^3.6.4",
"tsx": "^4.15.7",
"typescript": "^5.4.5"
}
}

99
backend/src/db.ts Normal file
View File

@@ -0,0 +1,99 @@
import { Pool } from 'pg';
import bcrypt from 'bcryptjs';
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function initDB() {
await pool.query(`
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
display_name VARCHAR(100) NOT NULL,
password_hash TEXT NOT NULL,
avatar_color VARCHAR(7) DEFAULT '#3b82f6',
bio TEXT DEFAULT '',
phone VARCHAR(20) DEFAULT '',
is_admin BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
last_seen TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS chats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(20) NOT NULL CHECK (type IN ('private','group','channel')),
title VARCHAR(200),
description TEXT DEFAULT '',
avatar_color VARCHAR(7) DEFAULT '#8b5cf6',
is_public BOOLEAN DEFAULT FALSE,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS chat_members (
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('owner','admin','member')),
can_send_messages BOOLEAN DEFAULT TRUE,
can_send_media BOOLEAN DEFAULT TRUE,
can_add_members BOOLEAN DEFAULT FALSE,
can_pin_messages BOOLEAN DEFAULT FALSE,
is_muted BOOLEAN DEFAULT FALSE,
joined_at TIMESTAMPTZ DEFAULT NOW(),
last_read_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (chat_id, user_id)
);
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
sender_id UUID REFERENCES users(id),
content TEXT NOT NULL DEFAULT '',
type VARCHAR(20) NOT NULL DEFAULT 'text' CHECK (type IN ('text','image','file','system')),
reply_to_id UUID REFERENCES messages(id),
is_edited BOOLEAN DEFAULT FALSE,
edited_at TIMESTAMPTZ,
is_deleted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size INTEGER NOT NULL,
url TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
auth_key TEXT NOT NULL,
p256dh TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (endpoint)
);
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_chat_members_user_id ON chat_members(user_id);
`);
// Seed admin if no users
const { rows } = await pool.query('SELECT COUNT(*) FROM users');
if (parseInt(rows[0].count) === 0) {
const hash = await bcrypt.hash('admin123', 10);
await pool.query(
`INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ($1,$2,$3,TRUE)`,
['admin', 'Администратор', hash]
);
console.log('Created admin user: admin / admin123');
}
}

71
backend/src/index.ts Normal file
View File

@@ -0,0 +1,71 @@
import Fastify from 'fastify';
import fastifyJwt from '@fastify/jwt';
import fastifyCors from '@fastify/cors';
import fastifyWebSocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import webpush from 'web-push';
import path from 'path';
import { initDB } from './db.js';
import { setupWebSocket } from './ws.js';
import authRoutes from './routes/auth.js';
import adminRoutes from './routes/admin.js';
import chatRoutes from './routes/chats.js';
import messageRoutes from './routes/messages.js';
import userRoutes from './routes/users.js';
import pushRoutes from './routes/push.js';
const app = Fastify({ logger: true });
// VAPID
webpush.setVapidDetails(
`mailto:${process.env.VAPID_EMAIL || 'admin@janichat.ru'}`,
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
// Plugins
await app.register(fastifyCors, {
origin: (process.env.ALLOWED_ORIGINS || '').split(','),
credentials: true,
});
await app.register(fastifyJwt, { secret: process.env.JWT_SECRET! });
await app.register(fastifyWebSocket);
await app.register(fastifyMultipart, {
limits: { fileSize: 50 * 1024 * 1024 },
});
await app.register(fastifyStatic, {
root: '/uploads',
prefix: '/uploads/',
});
// Auth decorator
app.decorate('authenticate', async function(request: any, reply: any) {
try {
await request.jwtVerify();
} catch {
reply.status(401).send({ error: 'Unauthorized' });
}
});
// Routes
await app.register(authRoutes, { prefix: '/api/auth' });
await app.register(adminRoutes, { prefix: '/api/admin' });
await app.register(chatRoutes, { prefix: '/api/chats' });
await app.register(messageRoutes, { prefix: '/api/messages' });
await app.register(userRoutes, { prefix: '/api/users' });
await app.register(pushRoutes, { prefix: '/api/push' });
// Health check
app.get('/health', async () => ({ ok: true }));
// WebSocket
setupWebSocket(app);
// Init
await initDB();
await app.listen({ port: parseInt(process.env.PORT || '3000'), host: '0.0.0.0' });

101
backend/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,101 @@
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { pool } from '../db.js';
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316'];
async function requireAdmin(req: any, reply: any) {
const { isAdmin } = req.user as { isAdmin: boolean };
if (!isAdmin) return reply.status(403).send({ error: 'Forbidden' });
}
export default async function adminRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
app.addHook('preHandler', requireAdmin);
// List users
app.get('/users', async () => {
const { rows } = await pool.query(
`SELECT id, username, display_name, avatar_color, is_admin, is_active, last_seen, created_at
FROM users ORDER BY created_at DESC`
);
return rows.map(u => ({
id: u.id, username: u.username, displayName: u.display_name,
avatarColor: u.avatar_color, isAdmin: u.is_admin, isActive: u.is_active,
lastSeen: u.last_seen, createdAt: u.created_at,
}));
});
// Create user
app.post('/users', async (req, reply) => {
const { username, displayName, password, isAdmin } = req.body as any;
const { id: createdBy } = req.user as { id: string };
if (!username || !displayName || !password) {
return reply.status(400).send({ error: 'Заполните все поля' });
}
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
const hash = await bcrypt.hash(password, 10);
try {
const { rows: [user] } = await pool.query(
`INSERT INTO users (username, display_name, password_hash, avatar_color, is_admin, created_by)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, username, display_name, avatar_color, is_admin`,
[username, displayName, hash, color, !!isAdmin, createdBy]
);
return reply.status(201).send({
id: user.id, username: user.username, displayName: user.display_name,
avatarColor: user.avatar_color, isAdmin: user.is_admin,
});
} catch (e: any) {
if (e.code === '23505') return reply.status(400).send({ error: 'Логин уже занят' });
throw e;
}
});
// Update user
app.put('/users/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const { displayName, isAdmin, isActive, password } = req.body as any;
if (password) {
const hash = await bcrypt.hash(password, 10);
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, id]);
}
await pool.query(
`UPDATE users SET
display_name = COALESCE($1, display_name),
is_admin = COALESCE($2, is_admin),
is_active = COALESCE($3, is_active)
WHERE id = $4`,
[displayName, isAdmin, isActive, id]
);
return { ok: true };
});
// Delete user
app.delete('/users/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const { id: selfId } = req.user as { id: string };
if (id === selfId) return reply.status(400).send({ error: 'Нельзя удалить себя' });
await pool.query('DELETE FROM users WHERE id = $1', [id]);
return { ok: true };
});
// Stats
app.get('/stats', async () => {
const [users, chats, messages] = await Promise.all([
pool.query('SELECT COUNT(*) FROM users'),
pool.query('SELECT COUNT(*) FROM chats'),
pool.query('SELECT COUNT(*) FROM messages WHERE is_deleted = FALSE'),
]);
return {
users: parseInt(users.rows[0].count),
chats: parseInt(chats.rows[0].count),
messages: parseInt(messages.rows[0].count),
};
});
}

View File

@@ -0,0 +1,76 @@
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { pool } from '../db.js';
export default async function authRoutes(app: FastifyInstance) {
app.post('/login', async (req, reply) => {
const { username, password } = req.body as { username: string; password: string };
const { rows: [user] } = await pool.query(
'SELECT * FROM users WHERE username = $1 AND is_active = TRUE', [username]
);
if (!user) return reply.status(401).send({ error: 'Неверный логин или пароль' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return reply.status(401).send({ error: 'Неверный логин или пароль' });
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [user.id]);
const token = app.jwt.sign({ id: user.id, isAdmin: user.is_admin }, { expiresIn: '30d' });
return {
token,
user: {
id: user.id,
username: user.username,
displayName: user.display_name,
avatarColor: user.avatar_color,
bio: user.bio,
isAdmin: user.is_admin,
}
};
});
app.get('/me', { preHandler: [app.authenticate] }, async (req) => {
const { id } = req.user as { id: string };
const { rows: [user] } = await pool.query(
'SELECT id, username, display_name, avatar_color, bio, phone, is_admin, last_seen FROM users WHERE id = $1', [id]
);
return {
id: user.id,
username: user.username,
displayName: user.display_name,
avatarColor: user.avatar_color,
bio: user.bio,
phone: user.phone,
isAdmin: user.is_admin,
lastSeen: user.last_seen,
};
});
app.put('/me', { preHandler: [app.authenticate] }, async (req, reply) => {
const { id } = req.user as { id: string };
const { displayName, bio, phone, password, newPassword } = req.body as any;
if (newPassword) {
const { rows: [user] } = await pool.query('SELECT password_hash FROM users WHERE id = $1', [id]);
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return reply.status(400).send({ error: 'Неверный текущий пароль' });
const hash = await bcrypt.hash(newPassword, 10);
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, id]);
}
await pool.query(
'UPDATE users SET display_name = COALESCE($1, display_name), bio = COALESCE($2, bio), phone = COALESCE($3, phone) WHERE id = $4',
[displayName, bio, phone, id]
);
const { rows: [user] } = await pool.query(
'SELECT id, username, display_name, avatar_color, bio, phone, is_admin FROM users WHERE id = $1', [id]
);
return {
id: user.id, username: user.username, displayName: user.display_name,
avatarColor: user.avatar_color, bio: user.bio, phone: user.phone, isAdmin: user.is_admin,
};
});
}

275
backend/src/routes/chats.ts Normal file
View File

@@ -0,0 +1,275 @@
import { FastifyInstance } from 'fastify';
import { pool } from '../db.js';
import { connections } from '../ws.js';
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316'];
export default async function chatRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
// Get my chats
app.get('/', async (req) => {
const { id: userId } = req.user as { id: string };
const { rows } = await pool.query(`
SELECT
c.id, c.type, c.title, c.description, c.avatar_color, c.is_public, c.created_at,
cm.role, cm.last_read_at,
(SELECT content FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT created_at FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1) AS last_message_at,
(SELECT COUNT(*) FROM messages WHERE chat_id = c.id AND is_deleted = FALSE AND created_at > cm.last_read_at) AS unread_count,
(SELECT COUNT(*) FROM chat_members WHERE chat_id = c.id) AS member_count,
-- For private chats, get the other user
CASE WHEN c.type = 'private' THEN (
SELECT u.display_name FROM users u
JOIN chat_members cm2 ON cm2.user_id = u.id
WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1
) END AS private_name,
CASE WHEN c.type = 'private' THEN (
SELECT u.avatar_color FROM users u
JOIN chat_members cm2 ON cm2.user_id = u.id
WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1
) END AS private_color,
CASE WHEN c.type = 'private' THEN (
SELECT u.id FROM users u
JOIN chat_members cm2 ON cm2.user_id = u.id
WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1
) END AS private_user_id
FROM chats c
JOIN chat_members cm ON cm.chat_id = c.id AND cm.user_id = $1
ORDER BY COALESCE(last_message_at, c.created_at) DESC
`, [userId]);
return rows.map(r => ({
id: r.id,
type: r.type,
title: r.type === 'private' ? r.private_name : r.title,
description: r.description,
avatarColor: r.type === 'private' ? r.private_color : r.avatar_color,
isPublic: r.is_public,
role: r.role,
lastMessage: r.last_message,
lastMessageAt: r.last_message_at,
unreadCount: parseInt(r.unread_count),
memberCount: parseInt(r.member_count),
privateUserId: r.private_user_id,
createdAt: r.created_at,
}));
});
// Get chat by id (with members)
app.get('/:id', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id } = req.params as { id: string };
const { rows: [member] } = await pool.query(
'SELECT role, can_send_messages, can_add_members FROM chat_members WHERE chat_id = $1 AND user_id = $2',
[id, userId]
);
if (!member) return reply.status(403).send({ error: 'Not a member' });
const { rows: [chat] } = await pool.query('SELECT * FROM chats WHERE id = $1', [id]);
if (!chat) return reply.status(404).send({ error: 'Not found' });
const { rows: members } = await pool.query(`
SELECT u.id, u.username, u.display_name, u.avatar_color, u.last_seen, cm.role, cm.can_send_messages
FROM chat_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.chat_id = $1
ORDER BY cm.role DESC, u.display_name
`, [id]);
const onlineSet = new Set(connections.keys());
return {
id: chat.id,
type: chat.type,
title: chat.title,
description: chat.description,
avatarColor: chat.avatar_color,
isPublic: chat.is_public,
myRole: member.role,
canSendMessages: member.can_send_messages,
canAddMembers: member.can_add_members,
createdAt: chat.created_at,
members: members.map(m => ({
id: m.id, username: m.username, displayName: m.display_name,
avatarColor: m.avatar_color, role: m.role, online: onlineSet.has(m.id),
lastSeen: m.last_seen, canSendMessages: m.can_send_messages,
})),
};
});
// Start or get private chat
app.post('/private/:userId', async (req, reply) => {
const { id: myId } = req.user as { id: string };
const { userId } = req.params as { userId: string };
if (myId === userId) return reply.status(400).send({ error: 'Cannot chat with yourself' });
// Check if private chat already exists
const { rows } = await pool.query(`
SELECT c.id FROM chats c
JOIN chat_members cm1 ON cm1.chat_id = c.id AND cm1.user_id = $1
JOIN chat_members cm2 ON cm2.chat_id = c.id AND cm2.user_id = $2
WHERE c.type = 'private'
LIMIT 1
`, [myId, userId]);
if (rows.length > 0) return { id: rows[0].id };
// Create new private chat
const { rows: [target] } = await pool.query('SELECT id FROM users WHERE id = $1', [userId]);
if (!target) return reply.status(404).send({ error: 'User not found' });
const { rows: [chat] } = await pool.query(
`INSERT INTO chats (type, created_by) VALUES ('private', $1) RETURNING id`, [myId]
);
await pool.query(
`INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member'),($1,$3,'member')`,
[chat.id, myId, userId]
);
return reply.status(201).send({ id: chat.id });
});
// Create group or channel
app.post('/', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { type, title, description, isPublic, memberIds } = req.body as any;
if (!['group', 'channel'].includes(type)) return reply.status(400).send({ error: 'Invalid type' });
if (!title) return reply.status(400).send({ error: 'Title required' });
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
const { rows: [chat] } = await pool.query(
`INSERT INTO chats (type, title, description, avatar_color, is_public, created_by)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
[type, title, description || '', color, !!isPublic, userId]
);
// Add creator as owner
await pool.query(
`INSERT INTO chat_members (chat_id, user_id, role, can_add_members, can_pin_messages)
VALUES ($1,$2,'owner',TRUE,TRUE)`,
[chat.id, userId]
);
// Add other members
if (Array.isArray(memberIds) && memberIds.length > 0) {
for (const mid of memberIds) {
if (mid === userId) continue;
await pool.query(
`INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member') ON CONFLICT DO NOTHING`,
[chat.id, mid]
);
}
}
return reply.status(201).send({ id: chat.id });
});
// Update chat
app.put('/:id', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id } = req.params as { id: string };
const { title, description } = req.body as any;
const { rows: [member] } = await pool.query(
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
);
if (!member || !['owner', 'admin'].includes(member.role)) {
return reply.status(403).send({ error: 'No permission' });
}
await pool.query(
'UPDATE chats SET title = COALESCE($1, title), description = COALESCE($2, description) WHERE id = $3',
[title, description, id]
);
return { ok: true };
});
// Add member
app.post('/:id/members', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id } = req.params as { id: string };
const { memberId } = req.body as { memberId: string };
const { rows: [member] } = await pool.query(
'SELECT role, can_add_members FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
);
if (!member || (!member.can_add_members && !['owner', 'admin'].includes(member.role))) {
return reply.status(403).send({ error: 'No permission' });
}
await pool.query(
`INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member') ON CONFLICT DO NOTHING`,
[id, memberId]
);
return { ok: true };
});
// Remove member
app.delete('/:id/members/:memberId', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id, memberId } = req.params as { id: string; memberId: string };
const { rows: [member] } = await pool.query(
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
);
if (!member || (!['owner', 'admin'].includes(member.role) && userId !== memberId)) {
return reply.status(403).send({ error: 'No permission' });
}
await pool.query('DELETE FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, memberId]);
return { ok: true };
});
// Update member role/permissions
app.put('/:id/members/:memberId', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id, memberId } = req.params as { id: string; memberId: string };
const { role, canSendMessages } = req.body as any;
const { rows: [member] } = await pool.query(
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
);
if (!member || !['owner', 'admin'].includes(member.role)) {
return reply.status(403).send({ error: 'No permission' });
}
await pool.query(
`UPDATE chat_members SET
role = COALESCE($1, role),
can_send_messages = COALESCE($2, can_send_messages)
WHERE chat_id = $3 AND user_id = $4`,
[role, canSendMessages, id, memberId]
);
return { ok: true };
});
// Leave chat
app.delete('/:id/leave', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id } = req.params as { id: string };
await pool.query('DELETE FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]);
return { ok: true };
});
// Delete chat (owner only)
app.delete('/:id', async (req, reply) => {
const { id: userId, isAdmin } = req.user as { id: string; isAdmin: boolean };
const { id } = req.params as { id: string };
const { rows: [member] } = await pool.query(
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
);
if (!member || (member.role !== 'owner' && !isAdmin)) {
return reply.status(403).send({ error: 'No permission' });
}
await pool.query('DELETE FROM chats WHERE id = $1', [id]);
return { ok: true };
});
}

View File

@@ -0,0 +1,137 @@
import { FastifyInstance } from 'fastify';
import { pool } from '../db.js';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
export default async function messageRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
// Get messages for a chat
app.get('/chat/:chatId', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { chatId } = req.params as { chatId: string };
const { before, limit = '50' } = req.query as any;
const { rows: [member] } = await pool.query(
'SELECT 1 FROM chat_members WHERE chat_id = $1 AND user_id = $2', [chatId, userId]
);
if (!member) return reply.status(403).send({ error: 'Not a member' });
const lim = Math.min(parseInt(limit), 100);
let query: string;
let params: any[];
if (before) {
query = `
SELECT m.*, u.username, u.display_name, u.avatar_color,
rm.content AS reply_content,
ru.display_name AS reply_sender_name,
(SELECT json_agg(json_build_object('id',a.id,'filename',a.filename,'originalName',a.original_name,'mimeType',a.mime_type,'size',a.size,'url',a.url))
FROM attachments a WHERE a.message_id = m.id) AS attachments
FROM messages m
LEFT JOIN users u ON u.id = m.sender_id
LEFT JOIN messages rm ON rm.id = m.reply_to_id
LEFT JOIN users ru ON ru.id = rm.sender_id
WHERE m.chat_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
ORDER BY m.created_at DESC LIMIT $3
`;
params = [chatId, before, lim];
} else {
query = `
SELECT m.*, u.username, u.display_name, u.avatar_color,
rm.content AS reply_content,
ru.display_name AS reply_sender_name,
(SELECT json_agg(json_build_object('id',a.id,'filename',a.filename,'originalName',a.original_name,'mimeType',a.mime_type,'size',a.size,'url',a.url))
FROM attachments a WHERE a.message_id = m.id) AS attachments
FROM messages m
LEFT JOIN users u ON u.id = m.sender_id
LEFT JOIN messages rm ON rm.id = m.reply_to_id
LEFT JOIN users ru ON ru.id = rm.sender_id
WHERE m.chat_id = $1
ORDER BY m.created_at DESC LIMIT $2
`;
params = [chatId, lim];
}
const { rows } = await pool.query(query, params);
// Update last_read_at
await pool.query(
'UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2',
[chatId, userId]
);
return rows.reverse().map(m => ({
id: m.id,
chatId: m.chat_id,
content: m.content,
type: m.type,
isDeleted: m.is_deleted,
isEdited: m.is_edited,
editedAt: m.edited_at,
createdAt: m.created_at,
replyTo: m.reply_to_id ? {
id: m.reply_to_id,
content: m.reply_content,
senderName: m.reply_sender_name,
} : null,
attachments: m.attachments || [],
sender: m.sender_id ? {
id: m.sender_id,
username: m.username,
displayName: m.display_name,
avatarColor: m.avatar_color,
} : null,
}));
});
// Upload file and send as message
app.post('/chat/:chatId/upload', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { chatId } = req.params as { chatId: string };
const { rows: [member] } = await pool.query(
'SELECT can_send_media FROM chat_members WHERE chat_id = $1 AND user_id = $2', [chatId, userId]
);
if (!member) return reply.status(403).send({ error: 'Not a member' });
if (!member.can_send_media) return reply.status(403).send({ error: 'No media permission' });
const data = await req.file();
if (!data) return reply.status(400).send({ error: 'No file' });
const ext = path.extname(data.filename);
const filename = `${crypto.randomUUID()}${ext}`;
const uploadDir = '/uploads';
fs.mkdirSync(uploadDir, { recursive: true });
const filepath = path.join(uploadDir, filename);
const buffer = await data.toBuffer();
fs.writeFileSync(filepath, buffer);
const isImage = data.mimetype.startsWith('image/');
const msgType = isImage ? 'image' : 'file';
const url = `/uploads/${filename}`;
const { rows: [msg] } = await pool.query(
`INSERT INTO messages (chat_id, sender_id, content, type) VALUES ($1,$2,$3,$4) RETURNING *`,
[chatId, userId, data.filename, msgType]
);
await pool.query(
`INSERT INTO attachments (message_id, filename, original_name, mime_type, size, url)
VALUES ($1,$2,$3,$4,$5,$6)`,
[msg.id, filename, data.filename, data.mimetype, buffer.length, url]
);
const { rows: [sender] } = await pool.query(
'SELECT id, username, display_name, avatar_color FROM users WHERE id = $1', [userId]
);
return {
id: msg.id, chatId: msg.chat_id, content: msg.content, type: msg.type,
createdAt: msg.created_at, sender,
attachments: [{ filename, originalName: data.filename, mimeType: data.mimetype, size: buffer.length, url }],
};
});
}

View File

@@ -0,0 +1,30 @@
import { FastifyInstance } from 'fastify';
import { pool } from '../db.js';
export default async function pushRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
app.get('/vapid-public-key', async () => {
return { key: process.env.VAPID_PUBLIC_KEY };
});
app.post('/subscribe', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { endpoint, keys } = req.body as { endpoint: string; keys: { auth: string; p256dh: string } };
await pool.query(
`INSERT INTO push_subscriptions (user_id, endpoint, auth_key, p256dh)
VALUES ($1,$2,$3,$4)
ON CONFLICT (endpoint) DO UPDATE SET user_id = $1, auth_key = $3, p256dh = $4`,
[userId, endpoint, keys.auth, keys.p256dh]
);
return reply.status(201).send({ ok: true });
});
app.delete('/subscribe', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { endpoint } = req.body as { endpoint: string };
await pool.query('DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2', [userId, endpoint]);
return { ok: true };
});
}

View File

@@ -0,0 +1,55 @@
import { FastifyInstance } from 'fastify';
import { pool } from '../db.js';
import { connections } from '../ws.js';
export default async function userRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
// Search users
app.get('/search', async (req) => {
const { q } = req.query as { q: string };
const { id: userId } = req.user as { id: string };
const { rows } = await pool.query(
`SELECT id, username, display_name, avatar_color, last_seen FROM users
WHERE is_active = TRUE AND id != $1
AND (username ILIKE $2 OR display_name ILIKE $2)
ORDER BY display_name LIMIT 20`,
[userId, `%${q}%`]
);
const online = connections;
return rows.map(u => ({
id: u.id, username: u.username, displayName: u.display_name,
avatarColor: u.avatar_color, online: online.has(u.id), lastSeen: u.last_seen,
}));
});
// Get all users (for add to chat)
app.get('/', async (req) => {
const { id: userId } = req.user as { id: string };
const { rows } = await pool.query(
`SELECT id, username, display_name, avatar_color, last_seen FROM users
WHERE is_active = TRUE AND id != $1 ORDER BY display_name`,
[userId]
);
const online = connections;
return rows.map(u => ({
id: u.id, username: u.username, displayName: u.display_name,
avatarColor: u.avatar_color, online: online.has(u.id), lastSeen: u.last_seen,
}));
});
// Get user profile
app.get('/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const { rows: [u] } = await pool.query(
'SELECT id, username, display_name, avatar_color, bio, last_seen FROM users WHERE id = $1 AND is_active = TRUE', [id]
);
if (!u) return reply.status(404).send({ error: 'Not found' });
return {
id: u.id, username: u.username, displayName: u.display_name,
avatarColor: u.avatar_color, bio: u.bio, online: connections.has(u.id), lastSeen: u.last_seen,
};
});
}

202
backend/src/ws.ts Normal file
View File

@@ -0,0 +1,202 @@
import { FastifyInstance } from 'fastify';
import { WebSocket } from 'ws';
import { pool } from './db.js';
import webpush from 'web-push';
// userId -> Set of WebSocket connections
export const connections = new Map<string, Set<WebSocket>>();
function broadcast(userIds: string[], data: object) {
const msg = JSON.stringify(data);
for (const uid of userIds) {
const sockets = connections.get(uid);
if (sockets) {
sockets.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
});
}
}
}
async function getChatMemberIds(chatId: string): Promise<string[]> {
const { rows } = await pool.query(
'SELECT user_id FROM chat_members WHERE chat_id = $1', [chatId]
);
return rows.map(r => r.user_id);
}
async function sendPushToOfflineUsers(userIds: string[], payload: object) {
const online = new Set(connections.keys());
const offline = userIds.filter(id => !online.has(id));
if (offline.length === 0) return;
const { rows } = await pool.query(
'SELECT endpoint, auth_key, p256dh FROM push_subscriptions WHERE user_id = ANY($1)',
[offline]
);
const body = JSON.stringify(payload);
for (const sub of rows) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { auth: sub.auth_key, p256dh: sub.p256dh } },
body
);
} catch (e: any) {
if (e.statusCode === 410) {
await pool.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint]);
}
}
}
}
export function setupWebSocket(app: FastifyInstance) {
app.get('/ws', { websocket: true }, async (socket, req) => {
let userId: string | null = null;
try {
const token = (req.query as any).token;
const decoded = app.jwt.verify<{ id: string }>(token);
userId = decoded.id;
} catch {
socket.close(1008, 'Unauthorized');
return;
}
// Register connection
if (!connections.has(userId)) connections.set(userId, new Set());
connections.get(userId)!.add(socket);
// Update last_seen
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [userId]);
// Notify others that user is online
const { rows: memberChats } = await pool.query(
'SELECT DISTINCT chat_id FROM chat_members WHERE user_id = $1', [userId]
);
for (const row of memberChats) {
const ids = await getChatMemberIds(row.chat_id);
broadcast(ids.filter(id => id !== userId), { type: 'user_online', payload: { userId, online: true } });
}
socket.on('message', async (raw) => {
try {
const { type, payload } = JSON.parse(raw.toString());
if (type === 'typing') {
const members = await getChatMemberIds(payload.chatId);
broadcast(members.filter(id => id !== userId), {
type: 'typing',
payload: { chatId: payload.chatId, userId, typing: payload.typing }
});
}
if (type === 'read_messages') {
await pool.query(
`UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2`,
[payload.chatId, userId]
);
const members = await getChatMemberIds(payload.chatId);
broadcast(members.filter(id => id !== userId), {
type: 'messages_read',
payload: { chatId: payload.chatId, userId }
});
}
if (type === 'send_message') {
// Check membership
const { rows: [member] } = await pool.query(
'SELECT role, can_send_messages FROM chat_members WHERE chat_id = $1 AND user_id = $2',
[payload.chatId, userId]
);
if (!member) return;
const { rows: [chat] } = await pool.query('SELECT type FROM chats WHERE id = $1', [payload.chatId]);
// Channels: only owner/admin can send
if (chat.type === 'channel' && !['owner', 'admin'].includes(member.role)) return;
if (!member.can_send_messages && member.role === 'member') return;
const { rows: [msg] } = await pool.query(
`INSERT INTO messages (chat_id, sender_id, content, type, reply_to_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[payload.chatId, userId, payload.content, payload.type || 'text', payload.replyToId || null]
);
// Get sender info
const { rows: [sender] } = await pool.query(
'SELECT id, username, display_name, avatar_color FROM users WHERE id = $1', [userId]
);
const fullMsg = { ...msg, sender };
const members = await getChatMemberIds(payload.chatId);
broadcast(members, { type: 'new_message', payload: fullMsg });
// Push to offline users
await sendPushToOfflineUsers(members, {
title: sender.display_name,
body: payload.content.substring(0, 100),
chatId: payload.chatId
});
// Update sender's last_read_at
await pool.query(
'UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2',
[payload.chatId, userId]
);
}
if (type === 'delete_message') {
const { rows: [msg] } = await pool.query(
'SELECT sender_id, chat_id FROM messages WHERE id = $1', [payload.messageId]
);
if (!msg) return;
const { rows: [member] } = await pool.query(
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [msg.chat_id, userId]
);
if (msg.sender_id !== userId && !['owner', 'admin'].includes(member?.role)) return;
await pool.query('UPDATE messages SET is_deleted = TRUE WHERE id = $1', [payload.messageId]);
const members = await getChatMemberIds(msg.chat_id);
broadcast(members, { type: 'message_deleted', payload: { messageId: payload.messageId, chatId: msg.chat_id } });
}
if (type === 'edit_message') {
const { rows: [msg] } = await pool.query(
'SELECT sender_id, chat_id FROM messages WHERE id = $1', [payload.messageId]
);
if (!msg || msg.sender_id !== userId) return;
const { rows: [updated] } = await pool.query(
'UPDATE messages SET content = $1, is_edited = TRUE, edited_at = NOW() WHERE id = $2 RETURNING *',
[payload.content, payload.messageId]
);
const members = await getChatMemberIds(msg.chat_id);
broadcast(members, { type: 'message_edited', payload: updated });
}
} catch (e) {
console.error('WS message error:', e);
}
});
socket.on('close', async () => {
if (!userId) return;
connections.get(userId)!.delete(socket);
if (connections.get(userId)!.size === 0) {
connections.delete(userId);
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [userId]);
const { rows: memberChats } = await pool.query(
'SELECT DISTINCT chat_id FROM chat_members WHERE user_id = $1', [userId]
);
for (const row of memberChats) {
const ids = await getChatMemberIds(row.chat_id);
broadcast(ids, { type: 'user_online', payload: { userId, online: false } });
}
}
});
});
}

17
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}