This commit is contained in:
payacom
2026-10-02 18:13:34 +03:30
parent d4ebc20aa8
commit fade8543e2
9 changed files with 180 additions and 63 deletions

View File

@@ -371,7 +371,7 @@ const sendRoomMessage = async (req, res, next) => {
}
const textContent = content != null ? String(content).trim() : ''
const uploaded = req.files?.file
const uploaded = req.file
const resolvedFileType = fileType || undefined
if (!textContent && !uploaded) {
@@ -409,22 +409,16 @@ const sendRoomMessage = async (req, res, next) => {
let fileUrl = null
if (uploaded) {
const uploadDir = path.join(__dirname, '../../../storage/chat-rooms/messages')
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
// اسمِ فایل هیچ‌وقت مستقیم از ورودیِ کاربر (uploaded.name) گرفته نمی‌شه — فقط پسوندش
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(uploaded.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(uploaded.path, filePath)
if (resolvedFileType === 'image' || (uploaded.type || '').startsWith('image/')) {
await optimizeImageFile(filePath)
} else if (resolvedFileType === 'video' || (uploaded.type || '').startsWith('video/')) {
optimizeVideoFaststart(filePath).catch((err) => {
// multer (roomMessageUpload توی routes) از قبل فایل رو مستقیماً روی پوشه‌ی
// نهایی نوشته — دیگه نیازی به fs.move جداگانه نیست
if (resolvedFileType === 'image' || (uploaded.mimetype || '').startsWith('image/')) {
await optimizeImageFile(uploaded.path)
} else if (resolvedFileType === 'video' || (uploaded.mimetype || '').startsWith('video/')) {
optimizeVideoFaststart(uploaded.path).catch((err) => {
console.error('[chatRoomController] video optimize background error:', err.message)
})
}
fileUrl = `/chat-rooms/messages/${uniqueFileName}`
fileUrl = `/chat-rooms/messages/${uploaded.filename}`
}
room.lastActivityAt = new Date()

View File

@@ -14,7 +14,7 @@ const {
consumeFreeCreation,
freeCreationLimitResponse
} = require('../../../utils/freeCreationLimit')
const { canUseFreeBillboard, consumeFreeBillboard } = require('../../../utils/billboardFreeLimit')
const { tryConsumeFreeBillboard } = require('../../../utils/billboardFreeLimit')
const {
PROJECT_ACCEPT_RATE,
PROJECT_COMPLETION_COMMISSION_RATE,
@@ -64,6 +64,18 @@ const initiatePayment = async (req, res) => {
const itemName = req.body.item_name // Get item name from request body
const price = await getPriceFromDatabase(itemName) // Get price from database
const projectId = req.body.projectId // Get project ID from request body
// IDOR: قبلاً هیچ‌جا چک نمی‌شد که این پروژه واقعاً متعلق به همین کاربرِ
// احرازهویت‌شده‌ست — یه projectId دلخواه کافی بود تا کاربرِ دیگه‌ای پرداخت کنه
// ولی پروژه‌ی یه کاربرِ کاملاً متفاوت تایید/پرداخت‌شده بشه
const projectForOwnerCheck = await ProjectModel.findById(projectId).select('creator_id')
if (!projectForOwnerCheck) {
return res.status(404).json({ message: 'پروژه یافت نشد' })
}
if (String(projectForOwnerCheck.creator_id) !== String(userId)) {
return res.status(403).json({ message: 'دسترسی به این پروژه ندارید' })
}
// Save project ID for later use in callback URL
req.session.projectId = projectId
const item = await TypeAndPriceModel.findOne({ name: itemName })
@@ -641,15 +653,24 @@ const initiateAdvertisingPayment = async (req, res) => {
const advertisingId = req.body.advertisingId // Get project ID from request body
const showDiscount = req.body.showDiscount
// IDOR: قبلاً هیچ‌جا چک نمی‌شد که این بیلبورد واقعاً متعلق به همین کاربرِ
// احرازهویت‌شده‌ست — یه advertisingId دلخواه کافی بود تا کاربرِ دیگه‌ای پرداخت
// کنه ولی بیلبوردِ یه کاربرِ کاملاً متفاوت تایید/پرداخت‌شده بشه
const advertisingForOwnerCheck = await AdvertisingModel.findById(advertisingId).select('creator_id')
if (!advertisingForOwnerCheck) {
return res.status(404).json({ message: 'بیلبورد یافت نشد' })
}
if (String(advertisingForOwnerCheck.creator_id) !== String(userId)) {
return res.status(403).json({ message: 'دسترسی به این بیلبورد ندارید' })
}
// Save project ID for later use in callback URL
req.session.advertisingId = advertisingId
const item = await AdvertisingTypeModel.findOne({ name: itemName })
const user = await UserModel.findById(userId)
if (item.name === 'free' && item.price === 0) {
if (canUseFreeBillboard(user)) {
await consumeFreeBillboard(user)
await user.save()
const freeResult = await tryConsumeFreeBillboard(UserModel, userId)
if (freeResult.ok) {
const project = await AdvertisingModel.findById(advertisingId)
if (!project) {
res.status(404).send('Advertising not found')
@@ -660,7 +681,7 @@ const initiateAdvertisingPayment = async (req, res) => {
await project.save()
res.status(200).json({ message: 'بیلبورد رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' })
} else {
res.status(403).json({ message: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' })
res.status(403).json({ message: freeResult.message })
}
} else {
const response = await axios.post(
@@ -796,12 +817,10 @@ const republishAdvertisingPayment = async (req, res) => {
// Save project ID for later use in callback URL
req.session.advertisingId = advertisingId
const item = await AdvertisingTypeModel.findOne({ name: itemName })
const user = await UserModel.findById(userId)
if (item.name === 'free' && item.price === 0) {
if (canUseFreeBillboard(user)) {
await consumeFreeBillboard(user)
await user.save()
const freeResult = await tryConsumeFreeBillboard(UserModel, userId)
if (freeResult.ok) {
const project = await AdvertisingModel.findById(advertisingId)
if (!project) {
res.status(404).send('Advertising not found')
@@ -812,7 +831,7 @@ const republishAdvertisingPayment = async (req, res) => {
await project.save()
res.status(200).json({ message: 'بیلبورد رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' })
} else {
res.status(403).json({ message: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' })
res.status(403).json({ message: freeResult.message })
}
} else {
const response = await axios.post(

View File

@@ -9,7 +9,7 @@ const NotificationModel = require('../../../models/NotificationModel')
const UserModel = require('../../../models/UserModel')
const AdvertisingTypeModel = require('../../../models/AdvertisingTypeModel')
const AdvertisingModel = require('../../../models/AdvertisingModel')
const { canUseFreeBillboard, consumeFreeBillboard } = require('../../../utils/billboardFreeLimit')
const { tryConsumeFreeBillboard } = require('../../../utils/billboardFreeLimit')
const OfferTypeModel = require('../../../models/OfferTypeModel')
const OfferModel = require('../../../models/OfferModel')
const { default: axios } = require('axios')
@@ -92,6 +92,17 @@ const initiatePaymentWeb = async (req, res) => {
})
}
// IDOR: قبلاً هیچ‌جا چک نمی‌شد که این پروژه واقعاً متعلق به همین کاربرِ
// احرازهویت‌شده‌ست — یه projectId دلخواه (مثلاً از یه لینکِ ساختگی) کافی بود تا
// کاربرِ دیگه‌ای پرداخت کنه ولی پروژه‌ی یه کاربرِ کاملاً متفاوت تایید/پرداخت‌شده بشه
const projectForOwnerCheck = await ProjectModel.findById(projectId).select('creator_id')
if (!projectForOwnerCheck) {
return res.status(404).json({ error: true, message: 'پروژه یافت نشد' })
}
if (String(projectForOwnerCheck.creator_id) !== String(userId)) {
return res.status(403).json({ error: true, message: 'دسترسی به این پروژه ندارید' })
}
const price = Number(item.price) || 0
const user = await UserModel.findById(userId)
if (!user) {
@@ -566,16 +577,25 @@ const initiateAdvertisingPaymentWeb = async (req, res) => {
const advertisingId = req.body.advertisingId // Get project ID from request body
const showDiscount = req.body.showDiscount
// IDOR: قبلاً هیچ‌جا چک نمی‌شد که این بیلبورد واقعاً متعلق به همین کاربرِ
// احرازهویت‌شده‌ست — یه advertisingId دلخواه کافی بود تا کاربرِ دیگه‌ای پرداخت
// کنه ولی بیلبوردِ یه کاربرِ کاملاً متفاوت تایید/پرداخت‌شده بشه (رایگان بود، فقط
// با مصرفِ سهمیه‌ی رایگانِ همون کاربر هم ممکن بود)
const advertisingForOwnerCheck = await AdvertisingModel.findById(advertisingId).select('creator_id')
if (!advertisingForOwnerCheck) {
return res.status(404).json({ message: 'بیلبورد یافت نشد' })
}
if (String(advertisingForOwnerCheck.creator_id) !== String(userId)) {
return res.status(403).json({ message: 'دسترسی به این بیلبورد ندارید' })
}
// Save project ID for later use in callback URL
req.session.advertisingId = advertisingId
const item = await AdvertisingTypeModel.findOne({ name: itemName })
const user = await UserModel.findById(userId)
if (item.name === 'free' && item.price === 0) {
if (canUseFreeBillboard(user)) {
await consumeFreeBillboard(user)
// updateOne + $inc به‌جای user.save() — رجوع کنید به توضیح مشابه در initiateOfferPaymentWeb
await UserModel.updateOne({ _id: userId }, { $inc: { free_billboard_count: 1 } })
const freeResult = await tryConsumeFreeBillboard(UserModel, userId)
if (freeResult.ok) {
const project = await AdvertisingModel.findById(advertisingId)
if (!project) {
res.status(404).send('Advertising not found')
@@ -586,7 +606,7 @@ const initiateAdvertisingPaymentWeb = async (req, res) => {
await project.save()
res.status(200).json({ message: 'بیلبورد رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' })
} else {
res.status(403).json({ message: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' })
res.status(403).json({ message: freeResult.message })
}
} else {
const response = await axios.post(
@@ -721,16 +741,25 @@ const republishAdvertisingPaymentWeb = async (req, res) => {
const price = await getAdvertisingPriceFromDatabase(itemName) // Get price from database
const advertisingId = req.body.advertisingId // Get project ID from request body
const showDiscount = req.body.showDiscount
// IDOR: قبلاً هیچ‌جا چک نمی‌شد که این بیلبورد واقعاً متعلق به همین کاربرِ
// احرازهویت‌شده‌ست — یه advertisingId دلخواه کافی بود تا کاربرِ دیگه‌ای پرداخت
// کنه ولی بیلبوردِ یه کاربرِ کاملاً متفاوت تایید/پرداخت‌شده بشه (رایگان بود، فقط
// با مصرفِ سهمیه‌ی رایگانِ همون کاربر هم ممکن بود)
const advertisingForOwnerCheck = await AdvertisingModel.findById(advertisingId).select('creator_id')
if (!advertisingForOwnerCheck) {
return res.status(404).json({ message: 'بیلبورد یافت نشد' })
}
if (String(advertisingForOwnerCheck.creator_id) !== String(userId)) {
return res.status(403).json({ message: 'دسترسی به این بیلبورد ندارید' })
}
// Save project ID for later use in callback URL
req.session.advertisingId = advertisingId
const item = await AdvertisingTypeModel.findOne({ name: itemName })
const user = await UserModel.findById(userId)
if (item.name === 'free' && item.price === 0) {
if (canUseFreeBillboard(user)) {
await consumeFreeBillboard(user)
// updateOne + $inc به‌جای user.save() — رجوع کنید به توضیح مشابه در initiateOfferPaymentWeb
await UserModel.updateOne({ _id: userId }, { $inc: { free_billboard_count: 1 } })
const freeResult = await tryConsumeFreeBillboard(UserModel, userId)
if (freeResult.ok) {
const project = await AdvertisingModel.findById(advertisingId)
if (!project) {
res.status(404).send('Advertising not found')
@@ -741,7 +770,7 @@ const republishAdvertisingPaymentWeb = async (req, res) => {
await project.save()
res.status(200).json({ message: 'بیلبورد رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' })
} else {
res.status(403).json({ message: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' })
res.status(403).json({ message: freeResult.message })
}
} else {
const response = await axios.post(

View File

@@ -69,6 +69,7 @@ const completeSocialSignup = async (req, res, next) => {
)
return res.status(422).json({
error: true,
code: 'duplicate_username',
field: 'user_name',
message: 'نام کاربری تکراری است',
suggestions

View File

@@ -59,6 +59,7 @@ const setUserName = async (req, res, next) => {
)
return res.status(422).json({
error: true,
code: 'duplicate_username',
message: 'نام کاربری تکراری است',
suggestions
})
@@ -113,6 +114,7 @@ const updateUserName = async (req, res, next) => {
)
return res.status(422).json({
error: true,
code: 'duplicate_username',
message: 'نام کاربری تکراری است',
suggestions
})

View File

@@ -12,6 +12,7 @@ try {
console.warn('[index] postVideoProcessing unavailable:', error.message);
}
const http = require('http').createServer(app);
const multer = require('multer');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const moment = require('moment-jalaali');
@@ -417,7 +418,31 @@ app.post('/api/v1/notification/send-sms', [blockCheck], async (req, res) => {
})
})
app.post('/api/v1/chat/file', [blockCheck, chatPeerBlock], async (req, res) => {
// قبلاً express-form-data به‌صورتِ global بدنه‌ی هر درخواستِ multipart رو می‌خوند و
// req.files رو پر می‌کرد؛ وقتی اون middleware (به‌خاطرِ تداخل با multer توی روت‌های
// دیگه مثل posts) حذف شد، این روت که مستقیماً به همون req.files تکیه داشت بدونِ هیچ
// parserِ جایگزینی موند — یعنی req.files همیشه undefined بود و هر ارسالِ عکس/ویدیو/
// فایل/ویس توی چت (وب و اندروید، چون هر دو همین یه اندپوینتِ مشترک رو صدا می‌زنن) با
// «Server error» شکست می‌خورد. همون الگوی multer.diskStorage که posts استفاده می‌کنه،
// اینجا هم (مستقیماً روی پوشه‌ی نهایی، بدونِ نیاز به fs.move جداگانه) وصل شد
const chatFileUploadDir = path.join(__dirname, '../storage/messages')
if (!fs.existsSync(chatFileUploadDir)) {
fs.mkdirSync(chatFileUploadDir, { recursive: true })
}
const chatFileUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, chatFileUploadDir),
filename: (req, file, cb) => {
// اسمِ فایل هیچ‌وقت مستقیم از ورودیِ کاربر (file.originalname) گرفته نمی‌شه —
// فقط پسوندش؛ وگرنه یه originalname مثل ../../../../etc/passwd می‌تونست از
// uploadDir بیرون بزنه (path traversal / نوشتنِ فایلِ دلخواه روی سرور)
cb(null, `${Date.now()}-${crypto.randomUUID()}${path.extname(file.originalname || '')}`)
}
}),
limits: { fileSize: 200 * 1024 * 1024 }
}).single('file')
app.post('/api/v1/chat/file', [blockCheck, chatFileUpload, chatPeerBlock], async (req, res) => {
try {
const senderId = String(req.user._id)
const { receiverId, content, replyToId, fileType, selfDestructSeconds, viewOnce } = req.body
@@ -448,27 +473,17 @@ app.post('/api/v1/chat/file', [blockCheck, chatPeerBlock], async (req, res) => {
if (viewOnceFlag && !isViewOnceMediaType(resolvedFileType)) {
return res.status(422).json({ error: 'View once is only for image, video, and voice' })
}
const { file } = req.files
const file = req.file
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)
if (resolvedFileType === 'image' || (file.type || '').startsWith('image/')) {
await optimizeImageFile(filePath)
} else if (resolvedFileType === 'video' || (file.type || '').startsWith('video/')) {
optimizeVideoFaststart(filePath).catch((err) => {
if (resolvedFileType === 'image' || (file.mimetype || '').startsWith('image/')) {
await optimizeImageFile(file.path)
} else if (resolvedFileType === 'video' || (file.mimetype || '').startsWith('video/')) {
optimizeVideoFaststart(file.path).catch((err) => {
console.error('[chat/file] video optimize background error:', err.message)
})
}
fileUrl = `/messages/${uniqueFileName}`
fileUrl = `/messages/${file.filename}`
}
const newMessage = new MessageModel({

View File

@@ -1,4 +1,8 @@
const express = require('express')
const path = require('path')
const fs = require('fs')
const crypto = require('crypto')
const multer = require('multer')
const {
listRooms,
createRoom,
@@ -15,6 +19,23 @@ const requireSubUserPermission = require('../../../middlewares/requireSubUserPer
const router = express.Router()
// همون دلیلِ /api/v1/chat/file توی index.js: این روت قبلاً به‌جای multer به
// express-form-data (که بعداً global حذف شد) تکیه داشت، پس req.files همیشه
// undefined بود و هر ارسالِ فایل/عکس/ویس/ویدیو توی چتِ روم هم شکست می‌خورد
const roomMessageUploadDir = path.join(__dirname, '../../../storage/chat-rooms/messages')
if (!fs.existsSync(roomMessageUploadDir)) {
fs.mkdirSync(roomMessageUploadDir, { recursive: true })
}
const roomMessageUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, roomMessageUploadDir),
filename: (req, file, cb) => {
cb(null, `${Date.now()}-${crypto.randomUUID()}${path.extname(file.originalname || '')}`)
}
}),
limits: { fileSize: 200 * 1024 * 1024 }
}).single('file')
router.get('/', listRooms)
router.post('/', createRoom)
router.get('/:roomId', getRoomDetail)
@@ -23,7 +44,7 @@ router.post('/:roomId/members', addRoomMembers)
router.get('/:roomId/e2ee-keys', getRoomE2eeKeys)
router.put('/:roomId/e2ee-keys', putRoomE2eeKeys)
router.get('/:roomId/messages', getRoomMessages)
router.post('/:roomId/messages', requireSubUserPermission('reply_dm'), sendRoomMessage)
router.post('/:roomId/messages', roomMessageUpload, requireSubUserPermission('reply_dm'), sendRoomMessage)
router.post('/:roomId/messages/reaction', reactToRoomMessage)
module.exports = router

View File

@@ -9,16 +9,28 @@ function canUseFreeBillboard (user) {
return used < BILLBOARD_FREE_LIMIT
}
async function consumeFreeBillboard (user) {
if (!canUseFreeBillboard(user)) {
/**
* نسخه‌ی atomic — چک (free_billboard_count < سقف) و افزایش توی یه عملیاتِ واحدِ
* MongoDB (findOneAndUpdate با شرط توی query) انجام می‌شه، نه در دو مرحله‌ی جدا
* (قبلاً canUseFreeBillboard روی یه user از قبل fetch‌شده چک می‌شد و بعد جداگانه
* $inc می‌خورد) — وگرنه دو درخواستِ هم‌زمان (دابل‌کلیکِ «تلاشِ دوباره» روی صفحه‌ی
* پرداختِ ناموفق، که تا امروز گاردِ submitting هم نداشت) هر دو می‌تونستن همون
* user رو با free_billboard_count زیرِ سقف بخونن و هر دو از سقف رد بشن
*/
async function tryConsumeFreeBillboard (UserModel, userId) {
const updated = await UserModel.findOneAndUpdate(
{ _id: userId, free_billboard_count: { $lt: BILLBOARD_FREE_LIMIT } },
{ $inc: { free_billboard_count: 1 } },
{ new: true }
)
if (!updated) {
return { ok: false, message: `سقف ${BILLBOARD_FREE_LIMIT} بیلبورد رایگان به پایان رسیده است.` }
}
user.free_billboard_count = (user.free_billboard_count || 0) + 1
return { ok: true }
return { ok: true, user: updated }
}
module.exports = {
BILLBOARD_FREE_LIMIT,
canUseFreeBillboard,
consumeFreeBillboard
tryConsumeFreeBillboard
}

View File

@@ -13,6 +13,13 @@ const { getBucketBalance } = require('../controllers/application/wallet/walletCo
* این‌طوری اگه کاربر پرداختِ درگاه رو نصفه ول کنه، هیچ مبلغی از کیف پول کم نشده که
* لازم باشه برگردونده بشه
*/
// زرین‌پال حداقل مبلغِ درگاه رو ۱۵٬۰۰۰ ریال (۱٬۵۰۰ تومان) می‌خواد — اگه بعدِ کسرِ
// کیف‌پول فقط یه باقی‌مونده‌ی مثبتِ کوچیک‌تر از این بمونه، کلِ درخواستِ initiate به
// زرین‌پال با خطای 422 رد می‌شه (نه فقط همون بخشِ کوچیک) — این دقیقاً همون چیزی بود
// که باعث می‌شد «ثبت درخواست» (و هر مسیرِ دیگه‌ای که از این تابع استفاده می‌کنه) با
// بعضی ترکیب‌های قیمت/موجودیِ کیف‌پول روی سرور ارور بده
const ZARINPAL_MIN_GATEWAY_TOMAN = 1500
async function resolveWalletSplit (userId, amount, useWallet) {
if (!useWallet) {
return { walletAmount: 0, gatewayAmount: amount, fullyCovered: false }
@@ -21,8 +28,25 @@ async function resolveWalletSplit (userId, amount, useWallet) {
if (!balance || balance <= 0) {
return { walletAmount: 0, gatewayAmount: amount, fullyCovered: false }
}
const walletAmount = Math.min(balance, amount)
const gatewayAmount = amount - walletAmount
let walletAmount = Math.min(balance, amount)
let gatewayAmount = amount - walletAmount
if (gatewayAmount > 0 && gatewayAmount < ZARINPAL_MIN_GATEWAY_TOMAN) {
const neededWalletAmount = amount - ZARINPAL_MIN_GATEWAY_TOMAN
if (neededWalletAmount >= 0 && balance >= neededWalletAmount) {
// از کیف‌پول کمتر استفاده کن تا باقی‌مونده دقیقاً به حداقلِ مجازِ درگاه برسه
walletAmount = neededWalletAmount
gatewayAmount = ZARINPAL_MIN_GATEWAY_TOMAN
} else {
// یا کلِ مبلغ از حداقلِ درگاه کمتره، یا کیف‌پول برای رسوندنِ باقی‌مونده به اون
// حداقل کافی نیست — اصلاً از کیف‌پول استفاده نکن، همه چیز از درگاه. این‌طوری
// حداقل یه خطای واضحِ «حداقل مبلغ» از خودِ زرین‌پال می‌گیریم، نه یه 422ِ گنگ
walletAmount = 0
gatewayAmount = amount
}
}
return { walletAmount, gatewayAmount, fullyCovered: gatewayAmount <= 0 }
}