const express = require('express'); const app = express(); const path = require('path'); const http = require('http').createServer(app); const cors = require('cors'); 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 blockCheck = require('./middlewares/blockCheck'); const rateLimit = require('express-rate-limit'); const { default: axios } = require('axios'); const allowedOrigins = [ 'http://localhost:3000', 'http://localhost:3001', // اضافه شد — مهم برای توسعه فرانت 'http://localhost:3002', 'http://localhost:3003', 'http://localhost', 'https://modstagram.com', 'http://modstagram.com', 'http://193.151.143.243:3004', 'https://www.modstagram.com', 'https://api.modstagram.com', 'https://panel.modstagram.com', 'https://www.panel.modstagram.com', 'https://panel.modstagram.ir', 'https://www.panel.modstagram.ir', '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 } }); // افزایش 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/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 = rateLimit({ 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 } = require('./helpers/expiredMessages') const { parseViewOnce, isViewOnceMediaType, sanitizeMessageForViewer, openViewOnceMessage, completeViewOnceMessage } = require('./helpers/viewOnceMessages') const mongoose = require('mongoose') app.get('/api/v1/chat', async (req, res) => { try { const { senderId, receiverId, limit, page } = req.query if (!senderId || !receiverId) { return res.status(400).json({ error: 'senderId and receiverId are required' }) } 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 } } ) 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], async (req, res) => { try { const { senderId, receiverId, content, replyToId, forwardedFrom: fwdBody, selfDestructSeconds } = req.body const { body, forwardedFrom: fwdParsed } = parseForwardedContent(content) const destructSec = parseSelfDestructSeconds(selfDestructSeconds) const payload = { senderId, receiverId, content: body || content, replyToId: replyToId || undefined, forwardedFrom: fwdBody || fwdParsed || undefined, expiresAt: buildExpiresAt(destructSec) } const newMessage = new MessageModel(payload) await newMessage.save() const formatted = await enrichMessage(newMessage) res.status(201).json({ data: formatted }) emitChatEvents(io, formatted, senderId, receiverId) scheduleMessageExpiry(io, newMessage) } catch (error) { console.error('Error sending message:', error) res.status(500).json({ error: 'Server error' }) } }) app.get('/api/v1/chat/read', async (req, res) => { try { const { senderId, receiverId } = req.query // شناسه فرستنده و گیرنده از درخواست دریافت شود // پیدا کردن تمام پیام‌هایی که کاربر دوم مشاهده کرده است و کاربر اول آنها را ارسال کرده است const messages = await MessageModel.find( { senderId: receiverId, receiverId: senderId } // برای مواقعی که شناسه‌ها معکوس باشند ) // به روزرسانی وضعیت خوانده شده یا نشده بودن پیام‌ها به "خوانده شده" await MessageModel.updateMany({ _id: { $in: messages.map(message => message._id) } }, { $set: { readStatus: 1 } }) 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' }) } }) app.post("/api/v1/notification/send-sms", async (req, res) => { const { receiverId, message } = req.body; const user = await UserModel.findById(receiverId); if (!user || !user.mobile) return res.status(404).json({ error: "User not found" }); console.log("3"); const mobile = user.mobile const user_name = user.user_name const data = JSON.stringify({ mobile, templateId: '876533', parameters: [ { name: 'USER', value: user_name } ] }) const config = { method: 'post', url: 'https://api.sms.ir/v1/send/verify', headers: { 'Content-Type': 'application/json', Accept: 'text/plain', 'x-api-key': '2JKxGbTzE64t0YTkjip6RYHnm9mq1haMlidF8WWwQunwubHFVW6iaMmfFQbMXEDt' }, data } axios(config) .then(function (response) { console.log(response); }) .catch(function (error) { console.log(error) }) res.status(200).json({ success: true }); }); app.post('/api/v1/chat/file', [blockCheck], async (req, res) => { try { const { senderId, receiverId, content, replyToId, fileType, selfDestructSeconds, viewOnce } = req.body const destructSec = parseSelfDestructSeconds(selfDestructSeconds) const viewOnceFlag = parseViewOnce(viewOnce) const resolvedFileType = fileType || undefined 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 }) } const uniqueFileName = `${Date.now()}-${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, expiresAt: buildExpiresAt(destructSec), viewOnce: viewOnceFlag }) await newMessage.save() const formatted = await enrichMessage(newMessage) res.status(201).json({ data: formatted }) emitChatEvents(io, formatted, senderId, receiverId) scheduleMessageExpiry(io, newMessage) } 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 deletedIds = await deleteMessagesAndNotify(io, messages, { deletedBy: userId }) res.json({ deletedIds, message: 'پیام‌ها حذف شدند' }) } catch (error) { console.error('Error deleting messages:', error) res.status(500).json({ error: 'Server error' }) } }) // Errors require('./middlewares/exception')(app) require('./middlewares/404')(app) io.on('connection', (socket) => { socket.on('joinUser', ({ userId }) => { if (userId) socket.join(`user:${userId}`) }) socket.on('joinChat', ({ userId, receiverId }) => { if (userId && receiverId) { socket.join(`chat:${userId}:${receiverId}`) socket.join(`chat:${receiverId}:${userId}`) } }) socket.on('typing', ({ senderId, receiverId }) => { io.to(`chat:${receiverId}:${senderId}`).emit('userTyping', { userId: senderId }) }) socket.on('stopTyping', ({ senderId, receiverId }) => { io.to(`chat:${receiverId}:${senderId}`).emit('userStoppedTyping', { userId: senderId }) }) socket.on('messageSeen', async ({ messageId, senderId, receiverId }) => { try { if (messageId) { await MessageModel.findByIdAndUpdate(messageId, { readStatus: 1 }) 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') cron.schedule('*/30 * * * * *', () => { purgeExpiredMessages(io).catch((err) => console.error('expired messages cron:', err)) }) cron.schedule('0 0 1 * *', async () => { try { await UserModel.updateMany({}, { daily_free_request: 1, last_free_request_date: new Date(), monthly_free_offer: 1, last_free_offer_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' }) module.exports = (port) => { http.listen(port, () => { console.log(`HTTP server is running on port ${port}`) }) }