This commit is contained in:
payacom
2026-08-31 20:45:41 +03:30
parent fa9f23c61d
commit 3f416a7a60
367 changed files with 621895 additions and 6443 deletions

347
index.js
View File

@@ -2,16 +2,25 @@ 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 rateLimit = require('express-rate-limit');
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',
@@ -24,11 +33,10 @@ const allowedOrigins = [
'http://modstagram.com',
'http://193.151.143.243:3004',
'https://www.modstagram.com',
'https://api.modstagram.com',
'https://app.modstagram.com',
'https://www.app.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های محلی (برای تست در شبکه داخلی)/
@@ -96,6 +104,8 @@ const io = require('socket.io')(http, {
}
});
app.set('io', io);
// افزایش limit برای Base64 (1000mb کافیه)
app.use(express.json({ limit: '1000mb' }));
app.use(express.urlencoded({ limit: '1000mb', extended: true }));
@@ -109,6 +119,10 @@ 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')));
@@ -136,7 +150,7 @@ app.use((req, res, next) => {
require('./middlewares')(app);
const limiter = rateLimit({
const limiter = createRateLimiter('global', {
windowMs: 3 * 60 * 1000,
max: 3000,
standardHeaders: true,
@@ -162,8 +176,13 @@ const {
activeMessageFilter,
purgeExpiredMessages,
scheduleMessageExpiry,
deleteMessagesAndNotify
deleteMessagesAndNotify,
isWithinDeleteWindow,
markSelfDestructViewed,
startPendingSelfDestructForViewer
} = require('./helpers/expiredMessages')
const { purgeInactiveChatRooms } = require('./controllers/application/chatRooms/chatRoomHelpers')
const { trackOfferMessageForCommission } = require('./utils/offerReplyGate')
const {
parseViewOnce,
isViewOnceMediaType,
@@ -174,14 +193,19 @@ const {
const mongoose = require('mongoose')
app.get('/api/v1/chat', async (req, res) => {
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))
@@ -214,6 +238,9 @@ app.get('/api/v1/chat', async (req, res) => {
{ _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) =>
@@ -227,19 +254,28 @@ app.get('/api/v1/chat', async (req, res) => {
}
})
app.post('/api/v1/chat', [blockCheck], async (req, res) => {
app.post('/api/v1/chat', [blockCheck, chatPeerBlock], async (req, res) => {
try {
const { senderId, receiverId, content, replyToId, forwardedFrom: fwdBody, selfDestructSeconds } = req.body
const { body, forwardedFrom: fwdParsed } = parseForwardedContent(content)
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 || content,
content: body || contentInput,
replyToId: replyToId || undefined,
forwardedFrom: fwdBody || fwdParsed || undefined,
expiresAt: buildExpiresAt(destructSec)
selfDestructSeconds: destructSec || undefined,
fileType: fileType || undefined
}
const newMessage = new MessageModel(payload)
@@ -248,21 +284,73 @@ app.post('/api/v1/chat', [blockCheck], async (req, res) => {
res.status(201).json({ data: formatted })
emitChatEvents(io, formatted, senderId, receiverId)
scheduleMessageExpiry(io, newMessage)
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.get('/api/v1/chat/read', async (req, res) => {
app.post('/api/v1/chat/reaction', [blockCheck, chatPeerBlock], async (req, res) => {
try {
const { senderId, receiverId } = req.query // شناسه فرستنده و گیرنده از درخواست دریافت شود
// پیدا کردن تمام پیام‌هایی که کاربر دوم مشاهده کرده است و کاربر اول آنها را ارسال کرده است
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 } // برای مواقعی که شناسه‌ها معکوس باشند
{ 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)
@@ -270,52 +358,26 @@ app.get('/api/v1/chat/read', async (req, res) => {
}
})
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 }
]
// این endpoint قبلاً بدون auth بود و کلید SMS را لو می‌داد — غیرفعال شد
app.post('/api/v1/notification/send-sms', [blockCheck], async (req, res) => {
return res.status(410).json({
error: true,
message: 'این سرویس غیرفعال شده است'
})
})
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) => {
app.post('/api/v1/chat/file', [blockCheck, chatPeerBlock], async (req, res) => {
try {
const { senderId, receiverId, content, replyToId, fileType, selfDestructSeconds, viewOnce } = req.body
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' })
}
@@ -326,7 +388,10 @@ app.post('/api/v1/chat/file', [blockCheck], async (req, res) => {
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
const uniqueFileName = `${Date.now()}-${file.name}`
// اسمِ فایل هیچ‌وقت مستقیم از ورودیِ کاربر (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}`
@@ -339,7 +404,7 @@ app.post('/api/v1/chat/file', [blockCheck], async (req, res) => {
file: fileUrl,
fileType: resolvedFileType,
replyToId: replyToId || undefined,
expiresAt: buildExpiresAt(destructSec),
selfDestructSeconds: destructSec || undefined,
viewOnce: viewOnceFlag
})
await newMessage.save()
@@ -347,7 +412,10 @@ app.post('/api/v1/chat/file', [blockCheck], async (req, res) => {
res.status(201).json({ data: formatted })
emitChatEvents(io, formatted, senderId, receiverId)
scheduleMessageExpiry(io, newMessage)
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' })
@@ -416,42 +484,145 @@ app.delete('/api/v1/chat', [blockCheck], async (req, res) => {
return res.status(403).json({ message: 'فقط پیام‌های خودتان قابل حذف هستند' })
}
const deletedIds = await deleteMessagesAndNotify(io, messages, { deletedBy: userId })
const deletable = messages.filter((m) => isWithinDeleteWindow(m.createdAt))
const tooOld = messages.filter((m) => !isWithinDeleteWindow(m.createdAt))
res.json({ deletedIds, message: 'پیام‌ها حذف شدند' })
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) => {
socket.on('joinUser', ({ userId }) => {
if (userId) socket.join(`user:${userId}`)
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('joinChat', ({ userId, receiverId }) => {
if (userId && receiverId) {
socket.join(`chat:${userId}:${receiverId}`)
socket.join(`chat:${receiverId}:${userId}`)
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('typing', ({ senderId, receiverId }) => {
io.to(`chat:${receiverId}:${senderId}`).emit('userTyping', { userId: senderId })
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('stopTyping', ({ senderId, receiverId }) => {
io.to(`chat:${receiverId}:${senderId}`).emit('userStoppedTyping', { userId: senderId })
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'
@@ -466,18 +637,30 @@ io.on('connection', (socket) => {
// 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(),
monthly_free_offer: 1,
last_free_offer_date: new Date()
last_free_request_date: new Date()
})
console.log('Monthly free requests reset for all users.')
} catch (error) {
@@ -487,6 +670,20 @@ cron.schedule('0 0 1 * *', async () => {
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}`);