Files
modstagram-back/index.js
payacom 3f416a7a60 full
2026-08-31 20:45:41 +03:30

691 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
});