691 lines
24 KiB
JavaScript
691 lines
24 KiB
JavaScript
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}`);
|
||
});
|