Spaces:
Runtime error
Runtime error
| const express = require('express'); | |
| const fs = require('fs'); | |
| const os = require('os'); | |
| const path = require('path'); | |
| const http = require('http'); | |
| const https = require('https'); | |
| const { Sequelize, DataTypes, Op } = require('sequelize'); | |
| const jwt = require('jsonwebtoken'); | |
| const bcrypt = require('bcryptjs'); | |
| const multer = require('multer'); | |
| const bodyParser = require('body-parser'); | |
| const { v4: uuidv4 } = require('uuid'); | |
| require('dotenv').config(); | |
| const app = express(); | |
| const cors = require('cors'); | |
| const allowedOrigins = process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',').map(o => o.trim()).filter(Boolean) : ['*']; | |
| function isAllowedOrigin(origin) { | |
| if (!origin) return true; | |
| if (allowedOrigins.includes('*')) return true; | |
| return allowedOrigins.some((allowed) => { | |
| if (allowed === origin) return true; | |
| if (allowed.endsWith(':*')) { | |
| const prefix = allowed.slice(0, -2); | |
| return origin.startsWith(prefix + ':'); | |
| } | |
| return false; | |
| }); | |
| } | |
| app.use(cors({ | |
| origin: (origin, callback) => { | |
| if (isAllowedOrigin(origin)) return callback(null, true); | |
| return callback(new Error('Not allowed by CORS')); | |
| }, | |
| credentials: true, | |
| methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], | |
| allowedHeaders: ['Content-Type', 'Authorization'] | |
| })); | |
| app.use(bodyParser.json({ limit: '50mb' })); | |
| app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); | |
| app.use((req, res, next) => { | |
| if (req.path.endsWith('.html') || req.path.endsWith('.js') || req.path.endsWith('.css')) { | |
| res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); | |
| res.setHeader('Pragma', 'no-cache'); | |
| res.setHeader('Expires', '0'); | |
| res.setHeader('Surrogate-Control', 'no-store'); | |
| } | |
| next(); | |
| }); | |
| app.use(express.static(__dirname)); | |
| app.get('/api/version', (req, res) => { | |
| try { | |
| const packageJson = require('./package.json'); | |
| res.json({ version: packageJson.version || 'unknown' }); | |
| } catch (error) { | |
| res.status(500).json({ version: 'unknown' }); | |
| } | |
| }); | |
| app.get('/api/rtc-config', (req, res) => { | |
| const defaultIceServers = [ | |
| { urls: 'stun:stun.l.google.com:19302' }, | |
| { urls: 'stun:stun1.l.google.com:19302' }, | |
| { urls: 'stun:stun2.l.google.com:19302' }, | |
| { urls: 'stun:stun3.l.google.com:19302' }, | |
| { urls: 'stun:stun4.l.google.com:19302' }, | |
| { urls: 'stun:stun.cloudflare.com:3478' } | |
| ]; | |
| const turnUrlsRaw = process.env.TURN_URLS || ''; | |
| const turnUsername = process.env.TURN_USERNAME || ''; | |
| const turnCredential = process.env.TURN_CREDENTIAL || ''; | |
| const relayFlag = String(process.env.RTC_FORCE_RELAY || 'false').toLowerCase(); | |
| const forceRelay = relayFlag === 'true' || relayFlag === '1' || relayFlag === 'yes'; | |
| let iceServers = [...defaultIceServers]; | |
| if (turnUrlsRaw && turnUsername && turnCredential) { | |
| const turnUrls = turnUrlsRaw | |
| .split(',') | |
| .map((entry) => entry.trim()) | |
| .filter(Boolean); | |
| if (turnUrls.length) { | |
| iceServers = [ | |
| ...defaultIceServers, | |
| { | |
| urls: turnUrls, | |
| username: turnUsername, | |
| credential: turnCredential | |
| } | |
| ]; | |
| } | |
| } | |
| res.json({ | |
| iceServers, | |
| iceTransportPolicy: forceRelay ? 'relay' : 'all' | |
| }); | |
| }); | |
| const isProduction = process.env.NODE_ENV === 'production'; | |
| const connectionString = process.env.DATABASE_URL; | |
| if (isProduction && !connectionString) { | |
| console.error('Missing DATABASE_URL in production. Add it in Render Environment settings or via render.yaml fromDatabase binding.'); | |
| process.exit(1); | |
| } | |
| const resolvedConnectionString = connectionString || 'postgres://postgres:postgres@localhost:5432/discord-app'; | |
| const isLocalConnection = resolvedConnectionString.includes('localhost') || resolvedConnectionString.includes('127.0.0.1'); | |
| const sequelize = new Sequelize(resolvedConnectionString, { | |
| dialect: 'postgres', | |
| logging: false, | |
| protocol: 'postgres', | |
| dialectOptions: isLocalConnection ? {} : { ssl: { require: true, rejectUnauthorized: false } } | |
| }); | |
| function sleep(ms) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| async function syncDatabaseWithRetry(maxAttempts = 8) { | |
| for (let attempt = 1; attempt <= maxAttempts; attempt++) { | |
| try { | |
| await sequelize.authenticate(); | |
| await sequelize.sync({ alter: true }); | |
| return; | |
| } catch (err) { | |
| const message = err && err.message ? err.message : String(err); | |
| const retryable = /ECONNRESET|ECONNREFUSED|ETIMEDOUT|SequelizeConnection/i.test(message); | |
| const shouldRetry = retryable && attempt < maxAttempts; | |
| console.error(`Database connect/sync attempt ${attempt}/${maxAttempts} failed:`, err); | |
| if (!shouldRetry) { | |
| throw err; | |
| } | |
| const delayMs = Math.min(30000, 2000 * attempt); | |
| console.log(`Retrying database connection in ${delayMs}ms...`); | |
| await sleep(delayMs); | |
| } | |
| } | |
| } | |
| const User = sequelize.define('User', { | |
| id: { | |
| type: DataTypes.UUID, | |
| defaultValue: DataTypes.UUIDV4, | |
| primaryKey: true | |
| }, | |
| username: { | |
| type: DataTypes.STRING, | |
| allowNull: false, | |
| unique: true | |
| }, | |
| email: { | |
| type: DataTypes.STRING, | |
| allowNull: false, | |
| unique: true | |
| }, | |
| password: { | |
| type: DataTypes.STRING, | |
| allowNull: false | |
| }, | |
| avatar: { | |
| type: DataTypes.TEXT, | |
| defaultValue: '' | |
| }, | |
| bio: { | |
| type: DataTypes.STRING, | |
| defaultValue: 'No bio yet' | |
| }, | |
| phoneNumber: { | |
| type: DataTypes.STRING, | |
| defaultValue: '' | |
| }, | |
| status: { | |
| type: DataTypes.ENUM('online', 'offline', 'away'), | |
| defaultValue: 'offline' | |
| }, | |
| coins: { | |
| type: DataTypes.INTEGER, | |
| defaultValue: 0 | |
| } | |
| }); | |
| const Channel = sequelize.define('Channel', { | |
| id: { | |
| type: DataTypes.UUID, | |
| defaultValue: DataTypes.UUIDV4, | |
| primaryKey: true | |
| }, | |
| channelId: DataTypes.STRING, | |
| name: DataTypes.STRING, | |
| type: { | |
| type: DataTypes.ENUM('text', 'voice'), | |
| defaultValue: 'text' | |
| }, | |
| description: DataTypes.STRING, | |
| isPrivate: { | |
| type: DataTypes.BOOLEAN, | |
| defaultValue: false | |
| } | |
| }); | |
| const Message = sequelize.define('Message', { | |
| id: { | |
| type: DataTypes.UUID, | |
| defaultValue: DataTypes.UUIDV4, | |
| primaryKey: true | |
| }, | |
| messageId: DataTypes.STRING, | |
| channelId: DataTypes.STRING, | |
| senderUsername: DataTypes.STRING, | |
| content: DataTypes.TEXT, | |
| mediaType: { | |
| type: DataTypes.ENUM('text', 'image', 'video', 'emoji', 'sticker', 'gif', 'voice'), | |
| defaultValue: 'text' | |
| }, | |
| mediaUrl: DataTypes.TEXT | |
| }); | |
| const ShopItem = sequelize.define('ShopItem', { | |
| id: { | |
| type: DataTypes.UUID, | |
| defaultValue: DataTypes.UUIDV4, | |
| primaryKey: true | |
| }, | |
| itemId: DataTypes.STRING, | |
| name: DataTypes.STRING, | |
| description: DataTypes.STRING, | |
| price: DataTypes.INTEGER, | |
| category: DataTypes.STRING, | |
| image: DataTypes.TEXT | |
| }); | |
| const Inventory = sequelize.define('Inventory', { | |
| id: { | |
| type: DataTypes.UUID, | |
| defaultValue: DataTypes.UUIDV4, | |
| primaryKey: true | |
| }, | |
| itemId: DataTypes.STRING, | |
| quantity: { | |
| type: DataTypes.INTEGER, | |
| defaultValue: 1 | |
| } | |
| }); | |
| const FriendRequest = sequelize.define('FriendRequest', { | |
| id: { | |
| type: DataTypes.UUID, | |
| defaultValue: DataTypes.UUIDV4, | |
| primaryKey: true | |
| }, | |
| status: { | |
| type: DataTypes.ENUM('pending', 'accepted', 'rejected'), | |
| defaultValue: 'pending' | |
| } | |
| }); | |
| const DirectMessage = sequelize.define('DirectMessage', { | |
| id: { | |
| type: DataTypes.UUID, | |
| defaultValue: DataTypes.UUIDV4, | |
| primaryKey: true | |
| }, | |
| messageId: DataTypes.STRING, | |
| fromUsername: DataTypes.STRING, | |
| toUsername: DataTypes.STRING, | |
| content: DataTypes.TEXT, | |
| mediaType: { | |
| type: DataTypes.ENUM('text', 'image', 'video', 'emoji', 'sticker', 'gif', 'voice'), | |
| defaultValue: 'text' | |
| }, | |
| mediaUrl: DataTypes.TEXT | |
| }); | |
| User.belongsToMany(User, { | |
| as: 'friends', | |
| through: 'UserFriends', | |
| foreignKey: 'UserId', | |
| otherKey: 'FriendId' | |
| }); | |
| User.hasMany(Channel, { foreignKey: 'createdById', as: 'channels' }); | |
| Channel.belongsTo(User, { foreignKey: 'createdById', as: 'creator' }); | |
| Channel.belongsToMany(User, { through: 'ChannelMembers', foreignKey: 'ChannelId', otherKey: 'UserId' }); | |
| User.belongsToMany(Channel, { through: 'ChannelMembers', foreignKey: 'UserId', otherKey: 'ChannelId' }); | |
| User.hasMany(Message, { foreignKey: 'senderId' }); | |
| Message.belongsTo(User, { foreignKey: 'senderId' }); | |
| User.hasMany(Inventory, { foreignKey: 'userId' }); | |
| Inventory.belongsTo(User, { foreignKey: 'userId' }); | |
| ShopItem.hasMany(Inventory, { foreignKey: 'shopItemId' }); | |
| Inventory.belongsTo(ShopItem, { foreignKey: 'shopItemId' }); | |
| FriendRequest.belongsTo(User, { as: 'from', foreignKey: 'fromId' }); | |
| FriendRequest.belongsTo(User, { as: 'to', foreignKey: 'toId' }); | |
| DirectMessage.belongsTo(User, { as: 'sender', foreignKey: 'fromId' }); | |
| DirectMessage.belongsTo(User, { as: 'recipient', foreignKey: 'toId' }); | |
| const authenticateToken = (req, res, next) => { | |
| const authHeader = req.headers['authorization']; | |
| const token = authHeader && authHeader.split(' ')[1]; | |
| if (!token) return res.status(401).json({ error: 'Access token required' }); | |
| jwt.verify(token, process.env.JWT_SECRET || 'secret_key', (err, user) => { | |
| if (err) return res.status(403).json({ error: 'Invalid token' }); | |
| req.user = user; | |
| next(); | |
| }); | |
| }; | |
| app.post('/api/register', async (req, res) => { | |
| try { | |
| const { username, email, password } = req.body; | |
| if (!username || !email || !password) { | |
| return res.status(400).json({ error: 'All fields required' }); | |
| } | |
| const existingUser = await User.findOne({ where: { [Op.or]: [{ username }, { email }] } }); | |
| if (existingUser) { | |
| return res.status(400).json({ error: existingUser.username === username ? 'Username already taken' : 'Email already registered' }); | |
| } | |
| const hashedPassword = await bcrypt.hash(password, 10); | |
| const user = await User.create({ username, email, password: hashedPassword }); | |
| const token = jwt.sign( | |
| { userId: user.id, username: user.username }, | |
| process.env.JWT_SECRET || 'secret_key' | |
| ); | |
| res.status(201).json({ | |
| token, | |
| user: { | |
| id: user.id, | |
| username: user.username, | |
| email: user.email, | |
| avatar: user.avatar, | |
| bio: user.bio, | |
| phoneNumber: user.phoneNumber, | |
| coins: user.coins | |
| } | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.post('/api/login', async (req, res) => { | |
| try { | |
| const { email, password } = req.body; | |
| if (!email || !password) { | |
| return res.status(400).json({ error: 'Email and password required' }); | |
| } | |
| const user = await User.findOne({ where: { email } }); | |
| if (!user) { | |
| return res.status(401).json({ error: 'Invalid credentials' }); | |
| } | |
| const validPassword = await bcrypt.compare(password, user.password); | |
| if (!validPassword) { | |
| return res.status(401).json({ error: 'Invalid credentials' }); | |
| } | |
| const token = jwt.sign( | |
| { userId: user.id, username: user.username }, | |
| process.env.JWT_SECRET || 'secret_key' | |
| ); | |
| res.json({ | |
| token, | |
| user: { | |
| id: user.id, | |
| username: user.username, | |
| email: user.email, | |
| avatar: user.avatar, | |
| bio: user.bio, | |
| phoneNumber: user.phoneNumber, | |
| coins: user.coins | |
| } | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.post('/api/reset-password', async (req, res) => { | |
| try { | |
| const { email, newPassword } = req.body; | |
| if (!email || !newPassword) { | |
| return res.status(400).json({ error: 'Email and new password are required' }); | |
| } | |
| if (String(newPassword).length < 6) { | |
| return res.status(400).json({ error: 'New password must be at least 6 characters' }); | |
| } | |
| const user = await User.findOne({ where: { email } }); | |
| if (!user) { | |
| return res.status(404).json({ error: 'No account found with this email' }); | |
| } | |
| user.password = await bcrypt.hash(newPassword, 10); | |
| await user.save(); | |
| res.json({ success: true, message: 'Password reset successfully' }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.get('/api/profile/:userId', authenticateToken, async (req, res) => { | |
| try { | |
| const user = await User.findByPk(req.params.userId, { | |
| attributes: ['id', 'username', 'avatar', 'bio', 'phoneNumber', 'coins', 'status'] | |
| }); | |
| if (!user) return res.status(404).json({ error: 'User not found' }); | |
| res.json(user); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.post('/api/profile/coins', authenticateToken, async (req, res) => { | |
| try { | |
| const { coins } = req.body; | |
| if (typeof coins !== 'number' || !Number.isFinite(coins) || coins < 0) { | |
| return res.status(400).json({ error: 'Invalid coins value' }); | |
| } | |
| const user = await User.findByPk(req.user.userId); | |
| if (!user) return res.status(404).json({ error: 'User not found' }); | |
| user.coins = Math.floor(coins); | |
| await user.save(); | |
| res.json({ message: 'Coins synced', coins: user.coins }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.put('/api/profile', authenticateToken, async (req, res) => { | |
| try { | |
| const { username, bio, avatar, phoneNumber } = req.body; | |
| const user = await User.findByPk(req.user.userId); | |
| if (!user) return res.status(404).json({ error: 'User not found' }); | |
| if (username && username !== user.username) { | |
| const existing = await User.findOne({ where: { username } }); | |
| if (existing) { | |
| return res.status(400).json({ error: 'Username already taken' }); | |
| } | |
| user.username = username; | |
| } | |
| if (bio) user.bio = bio; | |
| if (avatar) user.avatar = avatar; | |
| if (typeof phoneNumber === 'string') user.phoneNumber = phoneNumber; | |
| await user.save(); | |
| for (const [socketId, onlineUser] of onlineUsers.entries()) { | |
| if (onlineUser.userId === user.id) { | |
| onlineUsers.set(socketId, { | |
| ...onlineUser, | |
| username: user.username, | |
| avatar: user.avatar | |
| }); | |
| } | |
| } | |
| io.emit('users update', Array.from(onlineUsers.values())); | |
| res.json({ | |
| message: 'Profile updated', | |
| user: { | |
| id: user.id, | |
| username: user.username, | |
| avatar: user.avatar, | |
| bio: user.bio, | |
| phoneNumber: user.phoneNumber | |
| } | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.post('/api/profile/upload', authenticateToken, async (req, res) => { | |
| try { | |
| const { avatar } = req.body; | |
| const user = await User.findByPk(req.user.userId); | |
| if (!user) return res.status(404).json({ error: 'User not found' }); | |
| if (avatar) { | |
| user.avatar = avatar; | |
| await user.save(); | |
| for (const [socketId, onlineUser] of onlineUsers.entries()) { | |
| if (onlineUser.userId === user.id) { | |
| onlineUsers.set(socketId, { | |
| ...onlineUser, | |
| avatar: user.avatar | |
| }); | |
| } | |
| } | |
| io.emit('users update', Array.from(onlineUsers.values())); | |
| } | |
| res.json({ | |
| message: 'Profile image updated', | |
| avatar: user.avatar | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.post('/api/friends/request', authenticateToken, async (req, res) => { | |
| try { | |
| const { toUserId } = req.body; | |
| if (!toUserId) return res.status(400).json({ error: 'toUserId is required' }); | |
| if (String(toUserId) === String(req.user.userId)) return res.status(400).json({ error: 'Cannot send request to yourself' }); | |
| const targetUser = await User.findByPk(toUserId); | |
| if (!targetUser) return res.status(404).json({ error: 'User not found' }); | |
| const fromUser = await User.findByPk(req.user.userId); | |
| const existingFriend = await fromUser.getFriends({ where: { id: toUserId } }); | |
| if (existingFriend.length > 0) return res.status(400).json({ error: 'Already friends' }); | |
| const existingPending = await FriendRequest.findOne({ | |
| where: { | |
| status: 'pending', | |
| [Op.or]: [ | |
| { fromId: req.user.userId, toId: toUserId }, | |
| { fromId: toUserId, toId: req.user.userId } | |
| ] | |
| } | |
| }); | |
| if (existingPending) return res.status(400).json({ error: 'Friend request already pending' }); | |
| await FriendRequest.create({ fromId: req.user.userId, toId: toUserId }); | |
| io.emit('friend request update', { toUserId }); | |
| res.status(201).json({ message: 'Friend request sent' }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.get('/api/friends/requests', authenticateToken, async (req, res) => { | |
| try { | |
| const requests = await FriendRequest.findAll({ | |
| where: { toId: req.user.userId, status: 'pending' }, | |
| include: [{ model: User, as: 'from', attributes: ['id', 'username', 'avatar'] }] | |
| }); | |
| res.json(requests); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.post('/api/friends/accept', authenticateToken, async (req, res) => { | |
| try { | |
| const { requestId } = req.body; | |
| const request = await FriendRequest.findByPk(requestId); | |
| if (!request) return res.status(404).json({ error: 'Request not found' }); | |
| if (String(request.toId) !== String(req.user.userId)) { | |
| return res.status(403).json({ error: 'Not allowed to accept this request' }); | |
| } | |
| if (request.status !== 'pending') { | |
| return res.status(400).json({ error: `Request already ${request.status}` }); | |
| } | |
| request.status = 'accepted'; | |
| await request.save(); | |
| const fromUser = await User.findByPk(request.fromId); | |
| const toUser = await User.findByPk(request.toId); | |
| await fromUser.addFriend(toUser); | |
| await toUser.addFriend(fromUser); | |
| io.emit('friend request accepted', { | |
| requestId: request.id, | |
| fromId: request.fromId, | |
| toId: request.toId | |
| }); | |
| res.json({ message: 'Friend request accepted' }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.post('/api/friends/reject', authenticateToken, async (req, res) => { | |
| try { | |
| const { requestId } = req.body; | |
| const request = await FriendRequest.findByPk(requestId); | |
| if (!request) return res.status(404).json({ error: 'Request not found' }); | |
| if (String(request.toId) !== String(req.user.userId)) { | |
| return res.status(403).json({ error: 'Not allowed to reject this request' }); | |
| } | |
| if (request.status !== 'pending') { | |
| return res.status(400).json({ error: `Request already ${request.status}` }); | |
| } | |
| request.status = 'rejected'; | |
| await request.save(); | |
| io.emit('friend request rejected', { | |
| requestId: request.id, | |
| fromId: request.fromId, | |
| toId: request.toId | |
| }); | |
| res.json({ message: 'Friend request rejected' }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.get('/api/friends', authenticateToken, async (req, res) => { | |
| try { | |
| const user = await User.findByPk(req.user.userId, { | |
| include: { | |
| association: 'friends', | |
| attributes: ['id', 'username', 'avatar', 'status', 'bio'], | |
| through: { attributes: [] } | |
| } | |
| }); | |
| res.json(user.friends || []); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.get('/api/dms/:friendId', authenticateToken, async (req, res) => { | |
| try { | |
| const { friendId } = req.params; | |
| const messages = await DirectMessage.findAll({ | |
| where: { | |
| [Op.or]: [ | |
| { fromId: req.user.userId, toId: friendId }, | |
| { fromId: friendId, toId: req.user.userId } | |
| ] | |
| }, | |
| order: [['createdAt', 'ASC']] | |
| }); | |
| const userIds = [...new Set(messages.map(msg => msg.fromId))]; | |
| const users = await User.findAll({ | |
| where: { id: userIds }, | |
| attributes: ['id', 'avatar', 'username'] | |
| }); | |
| const userMap = new Map(users.map(user => [String(user.id), user])); | |
| const enriched = messages.map(msg => { | |
| const sender = userMap.get(String(msg.fromId)); | |
| return { | |
| ...msg.toJSON(), | |
| fromAvatar: sender?.avatar || null, | |
| fromUsername: sender?.username || msg.fromUsername, | |
| timestamp: msg.createdAt | |
| }; | |
| }); | |
| res.json(enriched); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.post('/api/dms', authenticateToken, async (req, res) => { | |
| try { | |
| const { toUserId, content, mediaType, mediaUrl } = req.body; | |
| const fromUser = await User.findByPk(req.user.userId); | |
| const toUser = await User.findByPk(toUserId); | |
| if (!toUser) return res.status(404).json({ error: 'User not found' }); | |
| const dm = await DirectMessage.create({ | |
| messageId: uuidv4(), | |
| fromId: req.user.userId, | |
| toId: toUserId, | |
| fromUsername: fromUser.username, | |
| toUsername: toUser.username, | |
| content, | |
| mediaType: mediaType || 'text', | |
| mediaUrl: mediaUrl || null | |
| }); | |
| res.status(201).json(dm); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.delete('/api/dms/:friendId', authenticateToken, async (req, res) => { | |
| try { | |
| const { friendId } = req.params; | |
| await DirectMessage.destroy({ | |
| where: { | |
| [Op.or]: [ | |
| { fromId: req.user.userId, toId: friendId }, | |
| { fromId: friendId, toId: req.user.userId } | |
| ] | |
| } | |
| }); | |
| emitToUser(friendId, 'dm history cleared', { | |
| fromUserId: req.user.userId, | |
| clearedAt: new Date().toISOString() | |
| }); | |
| res.json({ message: 'DM history cleared for both users' }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.get('/api/shop', authenticateToken, async (req, res) => { | |
| try { | |
| const items = await ensureShopItemsAvailable(); | |
| res.json(items); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.post('/api/shop/buy', authenticateToken, async (req, res) => { | |
| try { | |
| const requestedItemId = String(req.body?.itemId || '').trim(); | |
| if (!requestedItemId) { | |
| return res.status(400).json({ error: 'itemId is required' }); | |
| } | |
| let item = await ShopItem.findByPk(requestedItemId); | |
| if (!item) { | |
| item = await ShopItem.findOne({ where: { itemId: requestedItemId } }); | |
| } | |
| const user = await User.findByPk(req.user.userId); | |
| if (!item) return res.status(404).json({ error: 'Item not found' }); | |
| if (user.coins < item.price) return res.status(400).json({ error: 'Not enough coins' }); | |
| user.coins -= item.price; | |
| await user.save(); | |
| let inventoryItem = await Inventory.findOne({ | |
| where: { | |
| userId: user.id, | |
| shopItemId: item.id | |
| } | |
| }); | |
| if (inventoryItem) { | |
| inventoryItem.quantity += 1; | |
| await inventoryItem.save(); | |
| } else { | |
| inventoryItem = await Inventory.create({ userId: user.id, shopItemId: item.id, quantity: 1 }); | |
| } | |
| res.json({ | |
| message: 'Item purchased', | |
| coins: user.coins, | |
| purchasedItem: { | |
| id: item.id, | |
| itemId: item.itemId, | |
| name: item.name, | |
| category: item.category, | |
| description: item.description | |
| }, | |
| quantity: inventoryItem.quantity | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.get('/api/inventory', authenticateToken, async (req, res) => { | |
| try { | |
| const inventory = await Inventory.findAll({ | |
| where: { userId: req.user.userId }, | |
| include: [{ model: ShopItem }] | |
| }); | |
| res.json(inventory); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| function createServer(appInstance) { | |
| const pfxPath = process.env.SSL_PFX_PATH; | |
| const pfxPassphrase = process.env.SSL_PFX_PASSPHRASE; | |
| const keyPath = process.env.SSL_KEY_PATH; | |
| const certPath = process.env.SSL_CERT_PATH; | |
| if (pfxPath) { | |
| const httpsOptions = { | |
| pfx: fs.readFileSync(path.resolve(pfxPath)), | |
| passphrase: pfxPassphrase | |
| }; | |
| return { server: https.createServer(httpsOptions, appInstance), protocol: 'https' }; | |
| } | |
| if (keyPath && certPath) { | |
| const httpsOptions = { | |
| key: fs.readFileSync(path.resolve(keyPath)), | |
| cert: fs.readFileSync(path.resolve(certPath)) | |
| }; | |
| return { server: https.createServer(httpsOptions, appInstance), protocol: 'https' }; | |
| } | |
| return { server: http.createServer(appInstance), protocol: 'http' }; | |
| } | |
| const { server, protocol } = createServer(app); | |
| const io = require('socket.io')(server, { | |
| maxHttpBufferSize: 40 * 1024 * 1024, | |
| cors: { | |
| origin: (origin, callback) => { | |
| if (isAllowedOrigin(origin)) return callback(null, true); | |
| return callback(new Error('Not allowed by Socket.IO CORS')); | |
| }, | |
| methods: ['GET', 'POST'] | |
| } | |
| }); | |
| const onlineUsers = new Map(); | |
| const roomMessages = new Map(); | |
| const roomMembers = new Map(); | |
| const roomRoles = new Map(); | |
| const roomProfiles = new Map(); | |
| const roomMeta = new Map(); | |
| function normalizeRoomKey(roomType, roomName) { | |
| return String(roomType || 'community') + ':' + String(roomName || '').trim().toLowerCase(); | |
| } | |
| function getRoomMessages(roomType, roomName) { | |
| const key = normalizeRoomKey(roomType, roomName); | |
| return roomMessages.get(key) || []; | |
| } | |
| async function loadRoomMessagesFromDb(roomType, roomName, limit = 150) { | |
| const key = normalizeRoomKey(roomType, roomName); | |
| const rows = await Message.findAll({ | |
| where: { channelId: key }, | |
| include: [{ model: User, attributes: ['id', 'username', 'avatar'], required: false }], | |
| order: [['createdAt', 'ASC']], | |
| limit | |
| }); | |
| return rows.map((row) => ({ | |
| messageId: row.messageId || row.id, | |
| from: row.senderId || row.User?.id || null, | |
| fromUsername: row.senderUsername || row.User?.username || 'User', | |
| fromAvatar: row.User?.avatar || null, | |
| content: row.content || '', | |
| mediaType: row.mediaType || 'text', | |
| mediaUrl: row.mediaUrl || null, | |
| roomType, | |
| roomName, | |
| timestamp: row.createdAt | |
| })); | |
| } | |
| function appendRoomMessage(roomType, roomName, payload) { | |
| const key = normalizeRoomKey(roomType, roomName); | |
| const messages = roomMessages.get(key) || []; | |
| messages.push(payload); | |
| if (messages.length > 300) { | |
| messages.splice(0, messages.length - 300); | |
| } | |
| roomMessages.set(key, messages); | |
| } | |
| function emitToUser(userId, eventName, payload) { | |
| let delivered = 0; | |
| for (const [socketId, userData] of onlineUsers.entries()) { | |
| if (String(userData.userId) === String(userId)) { | |
| io.to(socketId).emit(eventName, payload); | |
| delivered += 1; | |
| } | |
| } | |
| return delivered; | |
| } | |
| function getRoomMembersPayload(roomType, roomName) { | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| const membersBySocket = roomMembers.get(roomKey) || new Map(); | |
| const dedupByUserId = new Map(); | |
| for (const member of membersBySocket.values()) { | |
| const userId = String(member.userId); | |
| if (!dedupByUserId.has(userId)) { | |
| dedupByUserId.set(userId, { | |
| id: member.userId, | |
| username: member.username, | |
| avatar: member.avatar || null, | |
| status: 'online', | |
| role: member.role || 'Member' | |
| }); | |
| } | |
| } | |
| return Array.from(dedupByUserId.values()); | |
| } | |
| function emitRoomMembersUpdate(roomType, roomName) { | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| io.to(roomKey).emit('room members update', { | |
| roomType, | |
| roomName, | |
| members: getRoomMembersPayload(roomType, roomName) | |
| }); | |
| } | |
| function upsertRoomMember(roomType, roomName, socket) { | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| if (!roomMembers.has(roomKey)) { | |
| roomMembers.set(roomKey, new Map()); | |
| } | |
| if (!roomRoles.has(roomKey)) { | |
| roomRoles.set(roomKey, new Map()); | |
| } | |
| const membersBySocket = roomMembers.get(roomKey); | |
| const rolesByUser = roomRoles.get(roomKey); | |
| const userIdKey = String(socket.userId); | |
| const meta = getOrCreateRoomMeta(roomType, roomName, socket.userId); | |
| if (!meta.creatorId) { | |
| meta.creatorId = userIdKey; | |
| } | |
| if (!rolesByUser.has(userIdKey)) { | |
| if (String(meta.creatorId) === userIdKey) { | |
| rolesByUser.set(userIdKey, 'Owner'); | |
| } else if (meta.contributorIds.has(userIdKey)) { | |
| rolesByUser.set(userIdKey, 'Admin'); | |
| } else { | |
| rolesByUser.set(userIdKey, 'Member'); | |
| } | |
| } | |
| membersBySocket.set(socket.id, { | |
| userId: socket.userId, | |
| username: socket.username, | |
| avatar: socket.userAvatar || null, | |
| role: rolesByUser.get(userIdKey) | |
| }); | |
| emitRoomMembersUpdate(roomType, roomName); | |
| } | |
| function removeRoomMember(roomType, roomName, socketId) { | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| const membersBySocket = roomMembers.get(roomKey); | |
| if (!membersBySocket) return; | |
| membersBySocket.delete(socketId); | |
| if (membersBySocket.size === 0) { | |
| roomMembers.delete(roomKey); | |
| roomRoles.delete(roomKey); | |
| roomMeta.delete(roomKey); | |
| return; | |
| } | |
| emitRoomMembersUpdate(roomType, roomName); | |
| } | |
| function emitUsersUpdate() { | |
| io.emit('users update', Array.from(onlineUsers.values())); | |
| } | |
| function getOrCreateRoomMeta(roomType, roomName, creatorId = null) { | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| if (!roomMeta.has(roomKey)) { | |
| roomMeta.set(roomKey, { | |
| creatorId: creatorId ? String(creatorId) : null, | |
| contributorIds: new Set(), | |
| updatedAt: new Date().toISOString() | |
| }); | |
| } | |
| return roomMeta.get(roomKey); | |
| } | |
| function getOrCreateRoomProfile(roomType, roomName) { | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| if (!roomProfiles.has(roomKey)) { | |
| roomProfiles.set(roomKey, { | |
| name: String(roomName || ''), | |
| icon: roomType === 'community' ? 'π' : 'π₯', | |
| image: '' | |
| }); | |
| } | |
| return roomProfiles.get(roomKey); | |
| } | |
| function emitRoomProfileUpdate(roomType, roomName, profile, previousRoomName = null) { | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| // Broadcast to room members first | |
| io.to(roomKey).emit('room profile updated', { | |
| roomType, | |
| roomName, | |
| previousRoomName, | |
| profile | |
| }); | |
| // Also broadcast to all connected users so they see updates in lists/modals even if not in the room | |
| io.emit('room profile updated broadcast', { | |
| roomType, | |
| roomName, | |
| previousRoomName, | |
| profile | |
| }); | |
| } | |
| function renameRoomState(roomType, previousRoomName, nextRoomName) { | |
| const oldKey = normalizeRoomKey(roomType, previousRoomName); | |
| const newKey = normalizeRoomKey(roomType, nextRoomName); | |
| if (oldKey === newKey) return newKey; | |
| if (roomMessages.has(oldKey)) { | |
| roomMessages.set(newKey, roomMessages.get(oldKey)); | |
| roomMessages.delete(oldKey); | |
| } | |
| if (roomRoles.has(oldKey)) { | |
| roomRoles.set(newKey, roomRoles.get(oldKey)); | |
| roomRoles.delete(oldKey); | |
| } | |
| if (roomProfiles.has(oldKey)) { | |
| roomProfiles.set(newKey, roomProfiles.get(oldKey)); | |
| roomProfiles.delete(oldKey); | |
| } | |
| if (roomMeta.has(oldKey)) { | |
| roomMeta.set(newKey, roomMeta.get(oldKey)); | |
| roomMeta.delete(oldKey); | |
| } | |
| if (roomMembers.has(oldKey)) { | |
| const membersBySocket = roomMembers.get(oldKey); | |
| roomMembers.set(newKey, membersBySocket); | |
| roomMembers.delete(oldKey); | |
| for (const socketId of membersBySocket.keys()) { | |
| const memberSocket = io.sockets.sockets.get(socketId); | |
| if (!memberSocket) continue; | |
| memberSocket.leave(oldKey); | |
| memberSocket.join(newKey); | |
| memberSocket.currentRoomKey = newKey; | |
| memberSocket.currentRoomMeta = { | |
| roomType, | |
| roomName: nextRoomName | |
| }; | |
| } | |
| } | |
| return newKey; | |
| } | |
| function getBotResponse(input = '') { | |
| const text = String(input || '').trim().toLowerCase(); | |
| const cleaned = text.replace(/^\/firetech\s*/i, '').replace(/^@firetech\s*/i, '').trim(); | |
| if (!cleaned || cleaned.includes('help')) { | |
| return 'FireTech commands: help, security, encryption, network, password, twofa, phishing, malware, vpn, hello'; | |
| } | |
| if (cleaned.includes('security')) { | |
| return 'Security tip: Keep software updated, use strong passwords, enable 2FA, and never share credentials.'; | |
| } | |
| if (cleaned.includes('encryption')) { | |
| return 'Encryption fact: End-to-end encryption ensures only sender and receiver can read messages. FireTech uses modern protocols.'; | |
| } | |
| if (cleaned.includes('network')) { | |
| return 'Network insight: Your messages are routed through secure servers. Network traffic is protected from eavesdropping.'; | |
| } | |
| if (cleaned.includes('password')) { | |
| return 'Password best practice: Use 12+ characters, mix uppercase/lowercase/numbers/symbols, and never reuse passwords.'; | |
| } | |
| if (cleaned.includes('2fa') || cleaned.includes('twofa')) { | |
| return '2FA (Two-Factor Authentication) significantly reduces account takeover risk. Enable it on all important accounts.'; | |
| } | |
| if (cleaned.includes('phishing')) { | |
| return 'Phishing warning: Check sender email, verify links before clicking, and never provide credentials to unsolicited requests.'; | |
| } | |
| if (cleaned.includes('malware')) { | |
| return 'Malware protection: Use antivirus software, avoid downloading from untrusted sources, and keep OS updated.'; | |
| } | |
| if (cleaned.includes('vpn')) { | |
| return 'VPN (Virtual Private Network) encrypts your connection and masks your IP. Useful on public WiFi networks.'; | |
| } | |
| if (cleaned.includes('hello') || cleaned.includes('hi')) { | |
| return 'β‘ FireTech Bot online. Your security is my priority. Ask for security tips or network info!'; | |
| } | |
| return `I heard: "${cleaned}". Type "help" for my security commands.`; | |
| } | |
| io.on('connection', (socket) => { | |
| console.log('User connected:', socket.id); | |
| socket.on('join', async (data) => { | |
| try { | |
| const decoded = jwt.verify(data.token, process.env.JWT_SECRET || 'secret_key'); | |
| const user = await User.findByPk(decoded.userId); | |
| if (user) { | |
| socket.userId = decoded.userId; | |
| socket.username = user.username; | |
| socket.userAvatar = user.avatar; | |
| onlineUsers.set(socket.id, { | |
| userId: decoded.userId, | |
| username: user.username, | |
| avatar: user.avatar, | |
| status: 'online' | |
| }); | |
| user.status = 'online'; | |
| await user.save(); | |
| emitUsersUpdate(); | |
| } | |
| } catch (error) { | |
| socket.emit('error', 'Authentication failed'); | |
| } | |
| }); | |
| socket.on('update profile cache', (data) => { | |
| const nextUsername = String(data?.username || socket.username || '').trim(); | |
| const nextAvatar = data?.avatar ?? socket.userAvatar ?? null; | |
| if (nextUsername) { | |
| socket.username = nextUsername; | |
| } | |
| socket.userAvatar = nextAvatar; | |
| const existing = onlineUsers.get(socket.id); | |
| if (existing) { | |
| existing.username = socket.username; | |
| existing.avatar = socket.userAvatar; | |
| onlineUsers.set(socket.id, existing); | |
| } | |
| if (socket.currentRoomMeta) { | |
| const { roomType, roomName } = socket.currentRoomMeta; | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| const membersBySocket = roomMembers.get(roomKey); | |
| if (membersBySocket && membersBySocket.has(socket.id)) { | |
| const member = membersBySocket.get(socket.id); | |
| member.username = socket.username; | |
| member.avatar = socket.userAvatar; | |
| membersBySocket.set(socket.id, member); | |
| emitRoomMembersUpdate(roomType, roomName); | |
| } | |
| } | |
| emitUsersUpdate(); | |
| }); | |
| socket.on('join dm', (friendId) => { | |
| const roomId = [socket.userId, friendId].sort().join('_'); | |
| socket.join(roomId); | |
| socket.currentDmRoom = roomId; | |
| }); | |
| socket.on('leave dm', (friendId) => { | |
| const roomId = [socket.userId, friendId].sort().join('_'); | |
| socket.leave(roomId); | |
| socket.currentDmRoom = null; | |
| }); | |
| socket.on('send dm', async (data) => { | |
| try { | |
| const { toUserId, content, mediaType, mediaUrl } = data; | |
| const fromUser = await User.findByPk(socket.userId); | |
| if (String(toUserId) === 'bot_firetech_0') { | |
| const dmPayload = { | |
| messageId: uuidv4(), | |
| from: socket.userId, | |
| fromUsername: fromUser.username, | |
| fromAvatar: fromUser.avatar, | |
| to: 'bot_firetech_0', | |
| toUsername: 'β‘ FireTech Bot', | |
| content, | |
| mediaType: mediaType || 'text', | |
| mediaUrl: mediaUrl || null, | |
| timestamp: new Date().toISOString() | |
| }; | |
| emitToUser(socket.userId, 'dm message', dmPayload); | |
| const botPayload = { | |
| messageId: uuidv4(), | |
| from: 'bot_firetech_0', | |
| fromUsername: 'β‘ FireTech Bot', | |
| fromAvatar: null, | |
| to: socket.userId, | |
| toUsername: fromUser.username, | |
| content: getBotResponse(content), | |
| mediaType: 'text', | |
| mediaUrl: null, | |
| isBot: true, | |
| timestamp: new Date().toISOString() | |
| }; | |
| setTimeout(() => { | |
| emitToUser(socket.userId, 'dm message', botPayload); | |
| }, 350 + Math.floor(Math.random() * 500)); | |
| return; | |
| } | |
| const toUser = await User.findByPk(toUserId); | |
| if (!toUser) { | |
| socket.emit('error', 'User not found'); | |
| return; | |
| } | |
| const dm = await DirectMessage.create({ | |
| messageId: uuidv4(), | |
| fromId: socket.userId, | |
| toId: toUserId, | |
| fromUsername: fromUser.username, | |
| toUsername: toUser.username, | |
| content, | |
| mediaType: mediaType || 'text', | |
| mediaUrl: mediaUrl || null | |
| }); | |
| const dmPayload = { | |
| messageId: dm.messageId, | |
| from: socket.userId, | |
| fromUsername: fromUser.username, | |
| fromAvatar: fromUser.avatar, | |
| to: toUserId, | |
| toUsername: toUser.username, | |
| content: dm.content, | |
| mediaType: dm.mediaType, | |
| mediaUrl: dm.mediaUrl, | |
| timestamp: dm.createdAt | |
| }; | |
| emitToUser(socket.userId, 'dm message', dmPayload); | |
| const deliveredToRecipient = emitToUser(toUserId, 'dm message', dmPayload); | |
| if (deliveredToRecipient === 0) { | |
| socket.emit('dm delivery status', { | |
| messageId: dm.messageId, | |
| toUserId, | |
| delivered: false | |
| }); | |
| } | |
| const rawContent = String(content || '').trim(); | |
| const askBot = rawContent.toLowerCase().startsWith('/firetech') || rawContent.toLowerCase().startsWith('@firetech'); | |
| if (askBot) { | |
| const botPayload = { | |
| messageId: uuidv4(), | |
| from: 'bot_firetech_0', | |
| fromUsername: 'β‘ FireTech Bot', | |
| fromAvatar: null, | |
| to: toUserId, | |
| toUsername: toUser.username, | |
| content: getBotResponse(rawContent), | |
| mediaType: 'text', | |
| mediaUrl: null, | |
| isBot: true, | |
| timestamp: new Date().toISOString() | |
| }; | |
| setTimeout(() => { | |
| emitToUser(socket.userId, 'dm message', botPayload); | |
| emitToUser(toUserId, 'dm message', botPayload); | |
| }, 400 + Math.floor(Math.random() * 600)); | |
| } | |
| } catch (error) { | |
| socket.emit('error', error.message); | |
| } | |
| }); | |
| socket.on('dm typing', (friendId) => { | |
| const roomId = [socket.userId, friendId].sort().join('_'); | |
| socket.broadcast.to(roomId).emit('dm user typing', { | |
| userId: socket.userId, | |
| username: socket.username | |
| }); | |
| }); | |
| socket.on('dm stop typing', (friendId) => { | |
| const roomId = [socket.userId, friendId].sort().join('_'); | |
| socket.broadcast.to(roomId).emit('dm user stop typing', { | |
| userId: socket.userId, | |
| username: socket.username | |
| }); | |
| }); | |
| socket.on('community typing', (data) => { | |
| const roomName = data?.community; | |
| if (!roomName) return; | |
| const roomKey = normalizeRoomKey('community', roomName); | |
| socket.broadcast.to(roomKey).emit('community user typing', { | |
| community: roomName, | |
| userId: socket.userId, | |
| username: socket.username | |
| }); | |
| }); | |
| socket.on('community stop typing', (data) => { | |
| const roomName = data?.community; | |
| if (!roomName) return; | |
| const roomKey = normalizeRoomKey('community', roomName); | |
| socket.broadcast.to(roomKey).emit('community user stop typing', { | |
| community: roomName, | |
| userId: socket.userId, | |
| username: socket.username | |
| }); | |
| }); | |
| socket.on('room typing', (data) => { | |
| const roomName = data?.roomName || data?.roomId; | |
| if (!roomName) return; | |
| const roomType = data?.roomType || 'group'; | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| socket.broadcast.to(roomKey).emit('room user typing', { | |
| roomType, | |
| roomId: roomName, | |
| userId: socket.userId, | |
| username: socket.username | |
| }); | |
| }); | |
| socket.on('room stop typing', (data) => { | |
| const roomName = data?.roomName || data?.roomId; | |
| if (!roomName) return; | |
| const roomType = data?.roomType || 'group'; | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| socket.broadcast.to(roomKey).emit('room user stop typing', { | |
| roomType, | |
| roomId: roomName, | |
| userId: socket.userId, | |
| username: socket.username | |
| }); | |
| }); | |
| socket.on('join room chat', async (data) => { | |
| const roomType = data?.roomType; | |
| const roomName = data?.roomName; | |
| if (!roomType || !roomName) return; | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| if (socket.currentRoomKey && socket.currentRoomKey !== roomKey) { | |
| if (socket.currentRoomMeta) { | |
| removeRoomMember(socket.currentRoomMeta.roomType, socket.currentRoomMeta.roomName, socket.id); | |
| } | |
| socket.leave(socket.currentRoomKey); | |
| } | |
| socket.join(roomKey); | |
| socket.currentRoomKey = roomKey; | |
| socket.currentRoomMeta = { roomType, roomName }; | |
| upsertRoomMember(roomType, roomName, socket); | |
| const roomProfile = getOrCreateRoomProfile(roomType, roomName); | |
| socket.emit('room profile updated', { | |
| roomType, | |
| roomName, | |
| previousRoomName: roomName, | |
| profile: roomProfile | |
| }); | |
| let history = getRoomMessages(roomType, roomName); | |
| try { | |
| history = await loadRoomMessagesFromDb(roomType, roomName, 180); | |
| roomMessages.set(roomKey, history); | |
| } catch (error) { | |
| console.warn('Failed loading DB room history:', error.message); | |
| } | |
| socket.emit('room chat history', { | |
| roomType, | |
| roomName, | |
| messages: history | |
| }); | |
| }); | |
| socket.on('leave room chat', (data) => { | |
| const roomType = data?.roomType; | |
| const roomName = data?.roomName; | |
| if (!roomType || !roomName) return; | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| socket.leave(roomKey); | |
| removeRoomMember(roomType, roomName, socket.id); | |
| if (socket.currentRoomKey === roomKey) { | |
| socket.currentRoomKey = null; | |
| socket.currentRoomMeta = null; | |
| } | |
| }); | |
| socket.on('set room role', (data) => { | |
| const roomType = data?.roomType; | |
| const roomName = data?.roomName; | |
| const targetUserId = data?.targetUserId; | |
| const nextRoleRaw = String(data?.role || '').trim(); | |
| const nextRole = ['Owner', 'Admin', 'Member'].includes(nextRoleRaw) ? nextRoleRaw : null; | |
| if (!roomType || !roomName || !targetUserId || !nextRole) return; | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| const rolesByUser = roomRoles.get(roomKey); | |
| if (!rolesByUser) return; | |
| const actorRole = rolesByUser.get(String(socket.userId)); | |
| if (actorRole !== 'Owner' && actorRole !== 'Admin') { | |
| socket.emit('error', 'Only Owner/Admin can change roles'); | |
| return; | |
| } | |
| if (actorRole === 'Admin' && nextRole === 'Owner') { | |
| socket.emit('error', 'Admin cannot assign Owner role'); | |
| return; | |
| } | |
| rolesByUser.set(String(targetUserId), nextRole); | |
| const membersBySocket = roomMembers.get(roomKey); | |
| if (membersBySocket) { | |
| for (const [memberSocketId, member] of membersBySocket.entries()) { | |
| if (String(member.userId) === String(targetUserId)) { | |
| member.role = nextRole; | |
| membersBySocket.set(memberSocketId, member); | |
| } | |
| } | |
| } | |
| emitRoomMembersUpdate(roomType, roomName); | |
| }); | |
| socket.on('update room profile', (data) => { | |
| const roomType = data?.roomType; | |
| const roomName = String(data?.roomName || '').trim(); | |
| const patch = data?.profilePatch || {}; | |
| if (!roomType || !roomName) return; | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| const meta = getOrCreateRoomMeta(roomType, roomName, socket.userId); | |
| if (!meta.creatorId) { | |
| meta.creatorId = String(socket.userId); | |
| } | |
| let rolesByUser = roomRoles.get(roomKey); | |
| if (!rolesByUser) { | |
| rolesByUser = new Map(); | |
| rolesByUser.set(String(socket.userId), 'Owner'); | |
| roomRoles.set(roomKey, rolesByUser); | |
| } | |
| const actorRole = rolesByUser.get(String(socket.userId)); | |
| let effectiveRole = actorRole; | |
| if (roomType === 'group' && !effectiveRole) { | |
| effectiveRole = 'Owner'; | |
| rolesByUser.set(String(socket.userId), effectiveRole); | |
| } | |
| if (effectiveRole !== 'Owner' && effectiveRole !== 'Admin') { | |
| socket.emit('error', roomType === 'group' | |
| ? 'Only Owner or Admin can edit group appearance' | |
| : 'Only Leader/Vice Leader can edit room appearance'); | |
| return; | |
| } | |
| if (effectiveRole === 'Owner' || effectiveRole === 'Admin') { | |
| meta.contributorIds.add(String(socket.userId)); | |
| meta.updatedAt = new Date().toISOString(); | |
| } | |
| const safeName = String(patch?.name || '').trim(); | |
| const safeIcon = typeof patch?.icon === 'string' ? patch.icon.trim().slice(0, 4) : null; | |
| const safeImage = typeof patch?.image === 'string' ? patch.image : null; | |
| let nextRoomName = roomName; | |
| if (safeName) { | |
| nextRoomName = safeName; | |
| renameRoomState(roomType, roomName, nextRoomName); | |
| } | |
| const profile = getOrCreateRoomProfile(roomType, nextRoomName); | |
| if (safeName) profile.name = safeName; | |
| if (safeIcon !== null) profile.icon = safeIcon; | |
| if (safeImage !== null) profile.image = safeImage; | |
| const nextRoomKey = normalizeRoomKey(roomType, nextRoomName); | |
| roomProfiles.set(nextRoomKey, profile); | |
| emitRoomProfileUpdate(roomType, nextRoomName, profile, roomName); | |
| emitRoomMembersUpdate(roomType, nextRoomName); | |
| }); | |
| socket.on('send room message', async (data) => { | |
| const roomType = data?.roomType; | |
| const roomName = data?.roomName; | |
| if (!roomType || !roomName) return; | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| const messagePayload = { | |
| messageId: uuidv4(), | |
| from: socket.userId, | |
| fromUsername: socket.username, | |
| fromAvatar: socket.userAvatar || null, | |
| content: data?.content || '', | |
| mediaType: data?.mediaType || 'text', | |
| mediaUrl: data?.mediaUrl || null, | |
| roomType, | |
| roomName, | |
| timestamp: new Date().toISOString() | |
| }; | |
| appendRoomMessage(roomType, roomName, messagePayload); | |
| try { | |
| await Message.create({ | |
| messageId: messagePayload.messageId, | |
| channelId: roomKey, | |
| senderId: socket.userId, | |
| senderUsername: socket.username, | |
| content: messagePayload.content, | |
| mediaType: messagePayload.mediaType, | |
| mediaUrl: messagePayload.mediaUrl || null | |
| }); | |
| } catch (error) { | |
| console.warn('Failed persisting room message:', error.message); | |
| } | |
| io.to(roomKey).emit('room message', messagePayload); | |
| const rawContent = String(data?.content || '').trim(); | |
| const askBot = rawContent.toLowerCase().startsWith('/firetech') || rawContent.toLowerCase().startsWith('@firetech'); | |
| if (askBot) { | |
| const botPayload = { | |
| messageId: uuidv4(), | |
| from: 'bot_firetech_0', | |
| fromUsername: 'β‘ FireTech Bot', | |
| fromAvatar: null, | |
| content: getBotResponse(rawContent), | |
| mediaType: 'text', | |
| mediaUrl: null, | |
| roomType, | |
| roomName, | |
| isBot: true, | |
| timestamp: new Date().toISOString() | |
| }; | |
| setTimeout(() => { | |
| appendRoomMessage(roomType, roomName, botPayload); | |
| io.to(roomKey).emit('room message', botPayload); | |
| }, 500 + Math.floor(Math.random() * 800)); | |
| } | |
| }); | |
| socket.on('clear room chat', (data) => { | |
| const roomType = data?.roomType; | |
| const roomName = data?.roomName; | |
| if (!roomType || !roomName) return; | |
| const roomKey = normalizeRoomKey(roomType, roomName); | |
| roomMessages.set(roomKey, []); | |
| Message.destroy({ where: { channelId: roomKey } }).catch(() => {}); | |
| io.to(roomKey).emit('room chat cleared', { roomType, roomName, clearedBy: socket.userId }); | |
| }); | |
| socket.on('send friend request', async (data) => { | |
| try { | |
| const toUserId = data?.toUserId; | |
| if (!toUserId) { | |
| socket.emit('error', 'Target user is required'); | |
| return; | |
| } | |
| if (String(toUserId) === String(socket.userId)) { | |
| socket.emit('error', 'Cannot send request to yourself'); | |
| return; | |
| } | |
| const targetUser = await User.findByPk(toUserId); | |
| if (!targetUser) { | |
| socket.emit('error', 'User not found'); | |
| return; | |
| } | |
| const fromUser = await User.findByPk(socket.userId); | |
| const existingFriend = await fromUser.getFriends({ where: { id: toUserId } }); | |
| if (existingFriend.length > 0) { | |
| socket.emit('error', 'Already friends'); | |
| return; | |
| } | |
| const existingPending = await FriendRequest.findOne({ | |
| where: { | |
| status: 'pending', | |
| [Op.or]: [ | |
| { fromId: socket.userId, toId: toUserId }, | |
| { fromId: toUserId, toId: socket.userId } | |
| ] | |
| } | |
| }); | |
| if (existingPending) { | |
| socket.emit('error', 'Friend request already pending'); | |
| return; | |
| } | |
| const newRequest = await FriendRequest.create({ fromId: socket.userId, toId: toUserId }); | |
| socket.emit('friend request sent', { requestId: newRequest.id, toUserId }); | |
| emitToUser(toUserId, 'friend request received', { | |
| requestId: newRequest.id, | |
| fromId: socket.userId, | |
| fromUsername: socket.username, | |
| fromAvatar: socket.userAvatar || null | |
| }); | |
| } catch (error) { | |
| socket.emit('error', error.message); | |
| } | |
| }); | |
| socket.on('start video call', async (data) => { | |
| try { | |
| const targetId = data?.targetId; | |
| if (!targetId) { | |
| socket.emit('error', 'Call target is required'); | |
| return; | |
| } | |
| const targetUser = await User.findByPk(targetId); | |
| if (!targetUser) { | |
| socket.emit('error', 'User not found'); | |
| return; | |
| } | |
| const callId = uuidv4(); | |
| const payload = { | |
| callId, | |
| fromId: socket.userId, | |
| fromUsername: socket.username, | |
| fromAvatar: socket.userAvatar || null, | |
| toId: targetId, | |
| type: data?.type || 'dm', | |
| startedAt: new Date().toISOString() | |
| }; | |
| const deliveredToTarget = emitToUser(targetId, 'incoming video call', payload); | |
| socket.emit('video call ringing', { | |
| ...payload, | |
| deliveredToTarget: deliveredToTarget > 0 | |
| }); | |
| if (deliveredToTarget === 0) { | |
| socket.emit('video call unavailable', { | |
| callId, | |
| targetId, | |
| message: 'User is currently offline or not connected.' | |
| }); | |
| } | |
| } catch (error) { | |
| socket.emit('error', error.message); | |
| } | |
| }); | |
| socket.on('accept video call', (data) => { | |
| const callId = data?.callId; | |
| const targetCallerId = data?.toId; | |
| if (!callId || !targetCallerId) return; | |
| emitToUser(targetCallerId, 'video call accepted', { | |
| callId, | |
| byUserId: socket.userId, | |
| byUsername: socket.username, | |
| acceptedAt: new Date().toISOString() | |
| }); | |
| }); | |
| socket.on('reject video call', (data) => { | |
| const callId = data?.callId; | |
| const targetCallerId = data?.toId; | |
| if (!callId || !targetCallerId) return; | |
| emitToUser(targetCallerId, 'video call rejected', { | |
| callId, | |
| byUserId: socket.userId, | |
| byUsername: socket.username, | |
| rejectedAt: new Date().toISOString() | |
| }); | |
| }); | |
| socket.on('end video call', (data) => { | |
| const callId = data?.callId; | |
| const peerUserId = data?.toId; | |
| if (!callId || !peerUserId) return; | |
| emitToUser(peerUserId, 'video call ended', { | |
| callId, | |
| byUserId: socket.userId, | |
| byUsername: socket.username, | |
| endedAt: new Date().toISOString() | |
| }); | |
| }); | |
| socket.on('video signal', (data) => { | |
| const toId = data?.toId; | |
| const signal = data?.signal; | |
| if (!toId || !signal) return; | |
| emitToUser(toId, 'video signal', { | |
| fromId: socket.userId, | |
| fromUsername: socket.username, | |
| callId: data?.callId || null, | |
| signal | |
| }); | |
| }); | |
| socket.on('disconnect', async () => { | |
| try { | |
| const userData = onlineUsers.get(socket.id); | |
| if (userData) { | |
| const user = await User.findByPk(socket.userId); | |
| if (user) { | |
| user.status = 'offline'; | |
| await user.save(); | |
| } | |
| onlineUsers.delete(socket.id); | |
| if (socket.currentRoomMeta) { | |
| removeRoomMember(socket.currentRoomMeta.roomType, socket.currentRoomMeta.roomName, socket.id); | |
| } | |
| emitUsersUpdate(); | |
| } | |
| } catch (error) { | |
| console.error(error); | |
| } | |
| }); | |
| }); | |
| function getLocalNetworkAddress() { | |
| const interfaces = os.networkInterfaces(); | |
| for (const name of Object.keys(interfaces)) { | |
| for (const net of interfaces[name]) { | |
| if (net.family === 'IPv4' && !net.internal) { | |
| return net.address; | |
| } | |
| } | |
| } | |
| return '127.0.0.1'; | |
| } | |
| async function migrateMediaTypeEnum() { | |
| try { | |
| const [results] = await sequelize.query(` | |
| SELECT EXISTS ( | |
| SELECT 1 FROM pg_type t | |
| JOIN pg_enum e ON t.oid = e.enumtypid | |
| WHERE t.typname = 'enum_DirectMessages_mediaType' | |
| AND e.enumlabel = 'voice' | |
| ) as has_voice; | |
| `); | |
| if (!results[0].has_voice) { | |
| console.log('π¦ Migrating mediaType ENUM to include sticker, gif, voice...'); | |
| await sequelize.query(` | |
| ALTER TYPE "enum_DirectMessages_mediaType" ADD VALUE IF NOT EXISTS 'sticker'; | |
| ALTER TYPE "enum_DirectMessages_mediaType" ADD VALUE IF NOT EXISTS 'gif'; | |
| ALTER TYPE "enum_DirectMessages_mediaType" ADD VALUE IF NOT EXISTS 'voice'; | |
| `); | |
| await sequelize.query(` | |
| ALTER TYPE "enum_Messages_mediaType" ADD VALUE IF NOT EXISTS 'sticker'; | |
| ALTER TYPE "enum_Messages_mediaType" ADD VALUE IF NOT EXISTS 'gif'; | |
| ALTER TYPE "enum_Messages_mediaType" ADD VALUE IF NOT EXISTS 'voice'; | |
| `); | |
| console.log('β ENUM migration complete!'); | |
| } else { | |
| console.log('β mediaType ENUM already up to date'); | |
| } | |
| } catch (error) { | |
| console.log('βΉοΈ ENUM migration skipped (likely first run or different DB)'); | |
| } | |
| } | |
| async function seedShopItems() { | |
| const existingCount = await ShopItem.count(); | |
| if (existingCount > 0) return; | |
| await ShopItem.bulkCreate(DEFAULT_SHOP_ITEMS); | |
| } | |
| const DEFAULT_SHOP_ITEMS = [ | |
| { itemId: 'banner_neon', name: 'Neon Banner Effect', description: 'Animated neon gradient banner for your profile.', price: 450, category: 'Banners', image: '' }, | |
| { itemId: 'badge_founder', name: 'Founder Badge', description: 'Exclusive badge shown next to your username.', price: 320, category: 'Badges', image: '' }, | |
| { itemId: 'color_pack_burst', name: 'Color Burst Pack', description: 'Unlock vibrant accent color themes.', price: 380, category: 'Themes', image: '' }, | |
| { itemId: 'chat_fx_glow', name: 'Message Glow FX', description: 'Subtle glow animation for sent messages.', price: 260, category: 'Effects', image: '' }, | |
| { itemId: 'avatar_ring_aura', name: 'Aura Avatar Ring', description: 'Premium animated ring around your avatar.', price: 520, category: 'Avatar', image: '' }, | |
| { itemId: 'nameplate_crystal', name: 'Crystal Nameplate', description: 'Polished nameplate style in member list.', price: 410, category: 'Nameplates', image: '' }, | |
| { itemId: 'theme_midnight', name: 'Midnight Theme Pack', description: 'Elegant dark gradients and UI accents.', price: 290, category: 'Themes', image: '' }, | |
| { itemId: 'emoji_pack_pro', name: 'Pro Emoji Pack', description: 'Premium emoji reactions collection.', price: 210, category: 'Emotes', image: '' } | |
| ]; | |
| async function ensureShopItemsAvailable() { | |
| let items = await ShopItem.findAll(); | |
| if (items.length > 0) return items; | |
| await ShopItem.bulkCreate(DEFAULT_SHOP_ITEMS); | |
| items = await ShopItem.findAll(); | |
| return items; | |
| } | |
| const PORT = process.env.PORT || 3000; | |
| const HOST = process.env.HOST || '0.0.0.0'; | |
| syncDatabaseWithRetry().then(async () => { | |
| await migrateMediaTypeEnum(); | |
| await seedShopItems(); | |
| server.listen(PORT, HOST, () => { | |
| const networkAddress = getLocalNetworkAddress(); | |
| console.log(` | |
| βββββββββββββββββββββββββββββββββββββββββ | |
| β π Discord-Like Server Running β | |
| β β | |
| β π‘ Local: ${protocol}://localhost:${PORT} β | |
| β π LAN: ${protocol}://${networkAddress}:${PORT} β | |
| β β¨ Status: Online & Ready β | |
| β β | |
| β Features: β | |
| β β User Authentication β | |
| β β Profiles & Friends β | |
| β β Text & Voice Channels β | |
| β β Media Sharing β | |
| β β Shop System β | |
| β β Real-time Messaging β | |
| β β PostgreSQL Database β | |
| β β | |
| βββββββββββββββββββββββββββββββββββββββββ | |
| `); | |
| }); | |
| }).catch(err => { | |
| console.error('Database startup error:', err); | |
| process.exit(1); | |
| }); | |