require('dotenv').config(); const express = require('express'); const app = express(); const path = require('path'); const crypto = require('crypto'); const http = require('http').createServer(app); const cors = require('cors'); const jwt = require('jsonwebtoken'); const moment = require('moment-jalaali'); const MessageModel = require('./models/MessageModel'); const fs = require('fs-extra'); const cron = require('node-cron'); const UserModel = require('./models/UserModel'); const { purgeExpiredDeactivatedAccounts, purgeExpiredTombstones } = require('./utils/accountLifecycle'); const blockCheck = require('./middlewares/blockCheck'); const chatPeerBlock = require('./middlewares/chatPeerBlock'); const createRateLimiter = require('./utils/createRateLimiter'); const { default: axios } = require('axios'); const { markOnline, markOffline } = require('./utils/presence'); const { invalidateUnreadMessages } = require('./utils/unreadMessagesCache'); const allowedOrigins = [ 'http://localhost:3000', 'http://localhost:3001', // اضافه شد — مهم برای توسعه فرانت 'http://localhost:3002', 'http://localhost:3003', 'http://localhost:3004', 'http://localhost', 'https://modstagram.com', 'http://modstagram.com', 'http://193.151.143.243:3004', 'https://www.modstagram.com', 'https://app.modstagram.com', 'https://www.app.modstagram.com', 'https://panel.modstagram.com', 'https://www.panel.modstagram.com', 'ionic://localhost', 'capacitor://localhost', // IPهای محلی (برای تست در شبکه داخلی)/ /^http:\/\/192\.168\.\d{1,3}\.\d{1,3}:\d+$/, /^http:\/\/10\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$/, ]; app.use(cors({ origin: (origin, callback) => { // در محیط توسعه، همه originها مجاز باشن if (process.env.NODE_ENV !== 'production') { return callback(null, true); } // اگر origin وجود نداشته باشه (مثل درخواست از موبایل یا Postman) اجازه بده if (!origin) { return callback(null, true); } // چک لیست یا RegExp const isAllowed = allowedOrigins.some(item => { if (typeof item === 'string') return item === origin; if (item instanceof RegExp) return item.test(origin); return false; }); if (isAllowed) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-no-toast'], credentials: true, optionsSuccessStatus: 200 // برای مرورگرهای قدیمی })); // Socket.IO CORS const io = require('socket.io')(http, { cors: { origin: (origin, callback) => { if (process.env.NODE_ENV !== 'production') { return callback(null, true); } if (!origin) { return callback(null, true); } const isAllowed = allowedOrigins.some(item => { if (typeof item === 'string') return item === origin; if (item instanceof RegExp) return item.test(origin); return false; }); if (isAllowed) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'], credentials: true } }); app.set('io', io); // افزایش limit برای Base64 (1000mb کافیه) app.use(express.json({ limit: '1000mb' })); app.use(express.urlencoded({ limit: '1000mb', extended: true })); // مسیرهای مشخص قبل از /storage عمومی — پروفایل از هر دو محل (قدیم و جدید) سرو می‌شود app.use('/storage/profiles', express.static(path.join(__dirname, '../storage/profiles'))); app.use('/storage/profiles', express.static(path.join(__dirname, 'storage/profiles'))); app.use('/storage/carts', express.static(path.join(__dirname, '../storage/carts'))); app.use('/storage/carts', express.static(path.join(__dirname, 'storage/carts'))); app.use('/storage/posts', express.static(path.join(__dirname, 'storage/posts'))); app.use('/storage/posts', express.static(path.join(__dirname, '../storage/posts'))); app.use('/storage/messages', express.static(path.join(__dirname, '../storage/messages'))); app.use('/storage/messages', express.static(path.join(__dirname, 'storage/messages'))); app.use('/storage/shop-chat', express.static(path.join(__dirname, '../storage/shop-chat'))); app.use('/storage/shop-chat', express.static(path.join(__dirname, 'storage/shop-chat'))); app.use('/storage/chat-rooms', express.static(path.join(__dirname, '../storage/chat-rooms'))); app.use('/storage/chat-rooms', express.static(path.join(__dirname, 'storage/chat-rooms'))); app.use('/storage/tickets', express.static(path.join(__dirname, '../storage/tickets'))); app.use('/storage/tickets', express.static(path.join(__dirname, 'storage/tickets'))); app.use('/storage/advertising', express.static(path.join(__dirname, '../storage/advertising'))); app.use('/storage/advertising', express.static(path.join(__dirname, 'storage/advertising'))); app.use('/storage/services', express.static(path.join(__dirname, '../storage/services'))); app.use('/storage/services', express.static(path.join(__dirname, 'storage/services'))); app.use('/storage', express.static(path.join(__dirname, 'storage'))); require('./boot'); // غیرفعال کردن پارس‌کننده‌های بدنه برای روت‌های آپلود فایل app.use((req, res, next) => { if (req.path === '/api/v1/posts/create' && req.method === 'POST') { console.log('DEBUG: Skipping body parsers for /api/v1/posts/create'); return next(); } express.json()(req, res, next); }); app.use((req, res, next) => { if (req.path === '/api/v1/posts/create' && req.method === 'POST') { return next(); } express.urlencoded({ extended: true })(req, res, next); }); require('./middlewares')(app); const limiter = createRateLimiter('global', { windowMs: 3 * 60 * 1000, max: 3000, standardHeaders: true, legacyHeaders: false, message: 'تعداد درخواست‌های شما بیشتر از حد مجاز است، لطفاً بعداً دوباره تلاش کنید.', handler: (req, res) => { const retryAfter = Math.ceil((req.rateLimit.resetTime - Date.now()) / 1000); res.set('Retry-After', retryAfter); res.status(429).json({ error: `شما بیش از حد مجاز درخواست ارسال کرده‌اید. لطفاً بعد از ${retryAfter} ثانیه دوباره تلاش کنید.` }); } }); app.use(limiter); app.set('trust proxy', 'loopback'); require('./routes')(app); const { enrichMessage, emitChatEvents, parseForwardedContent } = require('./helpers/chatHelpers') const { parseSelfDestructSeconds, buildExpiresAt, activeMessageFilter, purgeExpiredMessages, scheduleMessageExpiry, deleteMessagesAndNotify, isWithinDeleteWindow, markSelfDestructViewed, startPendingSelfDestructForViewer } = require('./helpers/expiredMessages') const { purgeInactiveChatRooms } = require('./controllers/application/chatRooms/chatRoomHelpers') const { trackOfferMessageForCommission } = require('./utils/offerReplyGate') const { parseViewOnce, isViewOnceMediaType, sanitizeMessageForViewer, openViewOnceMessage, completeViewOnceMessage } = require('./helpers/viewOnceMessages') const mongoose = require('mongoose') app.get('/api/v1/chat', [blockCheck, chatPeerBlock], async (req, res) => { try { const userId = String(req.user._id) const { senderId, receiverId, limit, page } = req.query if (!senderId || !receiverId) { return res.status(400).json({ error: 'senderId and receiverId are required' }) } if (String(senderId) !== userId) { return res.status(403).json({ error: 'Forbidden' }) } if ( !mongoose.Types.ObjectId.isValid(String(senderId)) || !mongoose.Types.ObjectId.isValid(String(receiverId)) ) { return res.status(400).json({ error: 'Invalid senderId or receiverId' }) } const sid = new mongoose.Types.ObjectId(String(senderId)) const rid = new mongoose.Types.ObjectId(String(receiverId)) const pageNumber = parseInt(page) || 1 const limitPerPage = parseInt(limit) || 40 const filter = activeMessageFilter({ $or: [ { senderId: sid, receiverId: rid }, { senderId: rid, receiverId: sid } ] }) const totalMessagesCount = await MessageModel.countDocuments(filter) const totalPages = Math.ceil(totalMessagesCount / limitPerPage) || 1 const skip = (pageNumber - 1) * limitPerPage const messages = await MessageModel.find(filter) .sort({ createdAt: -1 }) .skip(skip) .limit(limitPerPage) await MessageModel.updateMany( { _id: { $in: messages.map((m) => m._id) }, receiverId: sid }, { $set: { readStatus: 1 } } ) invalidateUnreadMessages(String(sid)).catch(() => {}) await startPendingSelfDestructForViewer(io, sid, rid) const enriched = await Promise.all( messages.map((m) => enrichMessage(sanitizeMessageForViewer(m, sid)) ) ) res.json({ messages: enriched.reverse(), totalPages }) } catch (error) { console.error('Error fetching messages:', error) res.status(500).json({ error: 'Server error' }) } }) app.post('/api/v1/chat', [blockCheck, chatPeerBlock], async (req, res) => { try { const senderId = String(req.user._id) const { receiverId, content, replyToId, forwardedFrom: fwdBody, selfDestructSeconds, fileType } = req.body // اگر کلاینت اشتباهاً آبجکت E2EE فرستاد، به رشته تبدیل کن const contentInput = content && typeof content === 'object' ? JSON.stringify(content) : content const { body, forwardedFrom: fwdParsed } = parseForwardedContent(contentInput) const destructSec = parseSelfDestructSeconds(selfDestructSeconds) if (!receiverId) { return res.status(422).json({ error: 'receiverId is required' }) } const payload = { senderId, receiverId, content: body || contentInput, replyToId: replyToId || undefined, forwardedFrom: fwdBody || fwdParsed || undefined, selfDestructSeconds: destructSec || undefined, fileType: fileType || undefined } const newMessage = new MessageModel(payload) await newMessage.save() const formatted = await enrichMessage(newMessage) res.status(201).json({ data: formatted }) emitChatEvents(io, formatted, senderId, receiverId) invalidateUnreadMessages(String(receiverId)).catch(() => {}) trackOfferMessageForCommission(io, senderId, receiverId).catch((err) => console.error('trackOfferMessageForCommission error:', err) ) } catch (error) { console.error('Error sending message:', error) res.status(500).json({ error: 'Server error' }) } }) app.post('/api/v1/chat/reaction', [blockCheck, chatPeerBlock], async (req, res) => { try { const userId = String(req.user._id) const { messageId, emoji } = req.body if (!messageId || !emoji) { return res.status(400).json({ error: 'messageId and emoji are required' }) } const msg = await MessageModel.findById(messageId) if (!msg) return res.status(404).json({ error: 'Message not found' }) const sid = String(msg.senderId) const rid = String(msg.receiverId) if (userId !== sid && userId !== rid) { return res.status(403).json({ error: 'Forbidden' }) } let reactions = Array.isArray(msg.reactions) ? [...msg.reactions] : [] const idx = reactions.findIndex((r) => String(r.userId) === userId) if (idx >= 0 && reactions[idx].emoji === emoji) { reactions.splice(idx, 1) } else if (idx >= 0) { reactions[idx] = { userId, emoji } } else { reactions.push({ userId, emoji }) } msg.reactions = reactions await msg.save() const formatted = await enrichMessage(msg) io.to(`chat:${sid}:${rid}`).to(`chat:${rid}:${sid}`).emit('messageReaction', { messageId: String(msg._id), reactions: formatted.reactions }) res.json({ data: formatted }) } catch (error) { console.error('Error updating reaction:', error) res.status(500).json({ error: 'Server error' }) } }) app.get('/api/v1/chat/read', [blockCheck, chatPeerBlock], async (req, res) => { try { const userId = String(req.user._id) const { senderId, receiverId } = req.query if (String(senderId) !== userId) { return res.status(403).json({ error: 'Forbidden' }) } const messages = await MessageModel.find( { senderId: receiverId, receiverId: senderId } ) await MessageModel.updateMany({ _id: { $in: messages.map(message => message._id) } }, { $set: { readStatus: 1 } }) invalidateUnreadMessages(userId).catch(() => {}) await startPendingSelfDestructForViewer(io, userId, receiverId) res.status(200).json({ message: 'Message read status updated successfully' }) } catch (error) { console.error('Error updating message read status:', error) res.status(500).json({ error: 'Server error' }) } }) // این endpoint قبلاً بدون auth بود و کلید SMS را لو می‌داد — غیرفعال شد app.post('/api/v1/notification/send-sms', [blockCheck], async (req, res) => { return res.status(410).json({ error: true, message: 'این سرویس غیرفعال شده است' }) }) app.post('/api/v1/chat/file', [blockCheck, chatPeerBlock], async (req, res) => { try { const senderId = String(req.user._id) const { receiverId, content, replyToId, fileType, selfDestructSeconds, viewOnce } = req.body const destructSec = parseSelfDestructSeconds(selfDestructSeconds) const viewOnceFlag = parseViewOnce(viewOnce) const resolvedFileType = fileType || undefined if (!receiverId) { return res.status(422).json({ error: 'receiverId is required' }) } if (viewOnceFlag && !isViewOnceMediaType(resolvedFileType)) { return res.status(422).json({ error: 'View once is only for image, video, and voice' }) } const { file } = req.files let fileUrl = null if (file) { const uploadDir = path.join(__dirname, '../storage/messages') if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }) } // اسمِ فایل هیچ‌وقت مستقیم از ورودیِ کاربر (file.name) گرفته نمی‌شه — فقط // پسوندش؛ وگرنه یه filename مثل ../../../../etc/passwd می‌تونست از uploadDir // بیرون بزنه (path traversal / نوشتنِ فایلِ دلخواه روی سرور) const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(file.name || '')}` const filePath = path.join(uploadDir, uniqueFileName) await fs.move(file.path, filePath) fileUrl = `/messages/${uniqueFileName}` } const newMessage = new MessageModel({ senderId, receiverId, content: content || '', file: fileUrl, fileType: resolvedFileType, replyToId: replyToId || undefined, selfDestructSeconds: destructSec || undefined, viewOnce: viewOnceFlag }) await newMessage.save() const formatted = await enrichMessage(newMessage) res.status(201).json({ data: formatted }) emitChatEvents(io, formatted, senderId, receiverId) invalidateUnreadMessages(String(receiverId)).catch(() => {}) trackOfferMessageForCommission(io, senderId, receiverId).catch((err) => console.error('trackOfferMessageForCommission error:', err) ) } catch (error) { console.error('Error sending message:', error) res.status(500).json({ error: 'Server error' }) } }) app.post('/api/v1/chat/view-once/open', [blockCheck], async (req, res) => { try { const userId = String(req.user._id) const { messageId } = req.body if (!messageId) { return res.status(422).json({ error: 'messageId is required' }) } const result = await openViewOnceMessage(messageId, userId) if (result.error) { return res.status(result.status || 400).json({ error: result.error }) } res.json({ file: result.file, fileType: result.fileType, messageId: String(result.message._id) }) } catch (error) { console.error('view-once open error:', error) res.status(500).json({ error: 'Server error' }) } }) app.post('/api/v1/chat/view-once/complete', [blockCheck], async (req, res) => { try { const userId = String(req.user._id) const { messageId } = req.body if (!messageId) { return res.status(422).json({ error: 'messageId is required' }) } const result = await completeViewOnceMessage(io, messageId, userId) if (result.error) { return res.status(result.status || 400).json({ error: result.error }) } res.json({ deletedIds: result.deletedIds }) } catch (error) { console.error('view-once complete error:', error) res.status(500).json({ error: 'Server error' }) } }) app.delete('/api/v1/chat', [blockCheck], async (req, res) => { try { const userId = String(req.user._id) const { messageIds } = req.body if (!Array.isArray(messageIds) || messageIds.length === 0) { return res.status(422).json({ message: 'پیامی انتخاب نشده است' }) } const messages = await MessageModel.find({ _id: { $in: messageIds }, senderId: userId }) if (!messages.length) { return res.status(403).json({ message: 'فقط پیام‌های خودتان قابل حذف هستند' }) } const deletable = messages.filter((m) => isWithinDeleteWindow(m.createdAt)) const tooOld = messages.filter((m) => !isWithinDeleteWindow(m.createdAt)) if (!deletable.length) { return res.status(403).json({ message: 'فقط پیام‌های ۲۴ ساعت گذشته قابل حذف هستند' }) } const deletedIds = await deleteMessagesAndNotify(io, deletable, { deletedBy: userId }) res.json({ deletedIds, skippedIds: tooOld.map((m) => String(m._id)), message: tooOld.length > 0 ? `${deletedIds.length} پیام حذف شد. ${tooOld.length} پیام قدیمی‌تر از ۲۴ ساعت بودند.` : 'پیام‌ها حذف شدند' }) } catch (error) { console.error('Error deleting messages:', error) res.status(500).json({ error: 'Server error' }) } }) app.post('/api/v1/chat/self-destruct/view', [blockCheck], async (req, res) => { try { const userId = String(req.user._id) const { messageId } = req.body if (!messageId) { return res.status(422).json({ error: 'messageId is required' }) } const result = await markSelfDestructViewed(io, messageId, userId) if (!result.message) { return res.status(404).json({ error: 'پیام یافت نشد' }) } res.json({ messageId: String(result.message._id), expiresAt: result.expiresAt }) } catch (error) { console.error('self-destruct view error:', error) res.status(500).json({ error: 'Server error' }) } }) // Errors require('./middlewares/exception')(app) require('./middlewares/404')(app) io.use((socket, next) => { try { const token = socket.handshake.auth?.token || (socket.handshake.headers?.authorization || '').split(' ')[1] if (!token) { return next(new Error('Unauthorized')) } const decoded = jwt.verify(token, process.env.APP_SECRET) if (!decoded?.id) { return next(new Error('Unauthorized')) } socket.data.userId = String(decoded.id) next() } catch (err) { next(new Error('Unauthorized')) } }) io.on('connection', (socket) => { const authenticatedUserId = () => String(socket.data.userId || '') // اتصالِ سوکت خودش یعنی کاربر الان آنلاینه — بدون این، وضعیتِ presence فقط با // درخواست‌های HTTP (middlewares/lastOnline.js) رفرش می‌شد و کاربری که فقط اپ رو // باز نگه داشته (بدون درخواستِ REST جدید) بعد از ۹۰ ثانیه اشتباهاً آفلاین می‌شد const uidOnConnect = authenticatedUserId() if (uidOnConnect) void markOnline(uidOnConnect) socket.on('disconnect', () => { const uid = authenticatedUserId() if (uid) void markOffline(uid) }) socket.on('joinUser', () => { const id = authenticatedUserId() if (id) socket.join(`user:${id}`) }) socket.on('joinChat', ({ receiverId }) => { const uid = authenticatedUserId() const rid = receiverId != null ? String(receiverId).trim() : '' if (uid && rid) { socket.join(`chat:${uid}:${rid}`) socket.join(`chat:${rid}:${uid}`) } }) socket.on('joinGroupRoom', async ({ roomId }) => { const uid = authenticatedUserId() const rid = roomId != null ? String(roomId).trim() : '' if (!uid || !rid || !mongoose.Types.ObjectId.isValid(rid)) return try { const ChatRoomModel = require('./models/ChatRoomModel') const room = await ChatRoomModel.findById(rid).select('members').lean() if (!room?.members?.some((m) => String(m) === uid)) return socket.join(`group:${rid}`) } catch (e) { console.error('joinGroupRoom auth error', e) } }) socket.on('typing', ({ receiverId }) => { const sid = authenticatedUserId() const rid = String(receiverId || '').trim() if (!sid || !rid) return io.to(`chat:${rid}:${sid}`).emit('userTyping', { userId: sid }) }) socket.on('stopTyping', ({ receiverId }) => { const sid = authenticatedUserId() const rid = String(receiverId || '').trim() if (!sid || !rid) return io.to(`chat:${rid}:${sid}`).emit('userStoppedTyping', { userId: sid }) }) socket.on('messageSeen', async ({ messageId, senderId, receiverId }) => { try { const uid = authenticatedUserId() if (!uid || String(receiverId) !== uid) return if (messageId) { await MessageModel.findByIdAndUpdate(messageId, { readStatus: 1 }) invalidateUnreadMessages(String(receiverId)).catch(() => {}) await markSelfDestructViewed(io, messageId, receiverId) io.to(`chat:${receiverId}:${senderId}`).emit('messageStatusUpdate', { messageId, status: 'seen' }) } } catch (e) { console.error('messageSeen error', e) } }) }) // Free Project require('./services/AdvertisingCron') require('./services/ProjectCron') require('./services/OrderLifecycleCron').setIo(io) require('./services/BookingLifecycleCron').setIo(io) require('./services/BookingRepeatExpiryCron').setIo(io) require('./services/SmartNotificationsCron').setIo(io) require('./services/InstagramMediaSyncCron') require('./utils/notifyFollowers').setIo(io) require('./utils/partnerMatch').setIo(io) cron.schedule('*/30 * * * * *', () => { purgeExpiredMessages(io).catch((err) => console.error('expired messages cron:', err)) }) cron.schedule('0 * * * *', () => { purgeInactiveChatRooms(io).catch((err) => console.error('inactive chat rooms cron:', err) ) }) cron.schedule('0 0 1 * *', async () => { try { // درخواست همکاری رایگان یک‌بار در کل عمر حساب است — دیگر ماهانه ریست نمی‌شود await UserModel.updateMany({}, { daily_free_request: 1, last_free_request_date: new Date() }) console.log('Monthly free requests reset for all users.') } catch (error) { console.error('Error resetting daily free requests:', error) } }, { scheduled: true, timezone: 'Asia/Tehran' }) cron.schedule('0 3 * * *', async () => { try { const purged = await purgeExpiredDeactivatedAccounts() await purgeExpiredTombstones() if (purged) console.log(`Purged ${purged} deactivated accounts`) } catch (error) { console.error('Account lifecycle cron error:', error) } }, { scheduled: true, timezone: 'Asia/Tehran' }) const port = process.env.APP_PORT || 3002; http.listen(port, () => { console.log(`HTTP server is running on port ${port}`); });