From d4ebc20aa8f3c2facaeb22eed927b02335f1b326 Mon Sep 17 00:00:00 2001 From: payacom Date: Thu, 1 Oct 2026 21:12:05 +0330 Subject: [PATCH] reels --- .../application/academy/academyControllers.js | 32 +-- .../application/account/e2eeController.js | 42 ++-- .../account/registerMobileController.js | 129 ++++++++++ .../chatRooms/chatRoomController.js | 12 + .../application/login/loginWithUserName.js | 66 +++++- .../payment/paymentControllerWeb.js | 30 ++- .../payment/shopOrderPaymentController.js | 20 +- .../application/posts/postController.js | 58 +++++ .../completeSocialSignupController.js | 96 ++++++++ .../application/stories/storyController.js | 7 + .../application/subUsers/subUserController.js | 220 ++++++++++++++++++ controllers/panel/users/usersController.js | 6 + index.js | 33 +++ middlewares/blockCheck.js | 15 +- middlewares/index.js | 7 +- middlewares/isBasicRegister.js | 18 +- middlewares/isHalfRegister.js | 28 ++- middlewares/isRegister.js | 28 ++- middlewares/requireSubUserPermission.js | 18 ++ models/UserModel.js | 39 +++- routes/application/academy/index.js | 7 +- routes/application/account/index.js | 11 +- routes/application/advertising/index.js | 5 +- routes/application/bookings/index.js | 11 +- routes/application/chat-rooms/index.js | 3 +- routes/application/orders/index.js | 5 +- routes/application/posts/index.js | 6 +- routes/application/projects/index.js | 11 +- routes/application/register/index.js | 3 + routes/application/shop-products/index.js | 5 +- routes/application/shops/index.js | 15 +- routes/application/stories/index.js | 4 +- routes/application/sub-users/index.js | 32 +++ routes/application/users/index.js | 3 +- routes/index.js | 2 + storage/.gitkeep | 0 utils/authSteps.js | 17 ++ utils/strongPasswordValidation.js | 34 +++ utils/subUserAuth.js | 40 ++++ utils/subUserPermissions.js | 27 +++ utils/verifyBadge.js | 5 +- 41 files changed, 1015 insertions(+), 135 deletions(-) create mode 100644 controllers/application/account/registerMobileController.js create mode 100644 controllers/application/register/completeSocialSignupController.js create mode 100644 controllers/application/subUsers/subUserController.js create mode 100644 middlewares/requireSubUserPermission.js create mode 100644 routes/application/sub-users/index.js delete mode 100644 storage/.gitkeep create mode 100644 utils/strongPasswordValidation.js create mode 100644 utils/subUserAuth.js create mode 100644 utils/subUserPermissions.js diff --git a/controllers/application/academy/academyControllers.js b/controllers/application/academy/academyControllers.js index b8e2edd..7520507 100644 --- a/controllers/application/academy/academyControllers.js +++ b/controllers/application/academy/academyControllers.js @@ -302,18 +302,10 @@ const parseCourseContactInfo = (body) => { const creatCourse = async (req, res, next) => { try { - const token = req.header('Authorization').split(' ')[1] - if (!token) return res.status(401).send('Access Denied') - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id - - const user = await UserModel.findById(userId) - if (!user) { - return res.status(422).json({ - error: true, - message: 'کاربر یافت نشد' - }) - } + // با blockCheck ست می‌شه (req.user) — برای کاربرِ زیرمجموعه، همون اکانتِ اصلیه + // (نه خودِ زیرمجموعه)، پس آکادمی درست پیدا می‌شه + const user = req.user + const userId = user._id const academy = await AcademyModel.findOne({ userId }) if (!academy) { @@ -388,11 +380,9 @@ const creatCourse = async (req, res, next) => { const updateCourse = async (req, res, next) => { try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) return res.status(401).send('Access Denied') - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id + // با blockCheck ست می‌شه (req.user) — برای کاربرِ زیرمجموعه، همون اکانتِ اصلیه + const user = req.user + const userId = user._id const { price, @@ -422,14 +412,6 @@ const updateCourse = async (req, res, next) => { }) } - const user = await UserModel.findById(userId) - if (!user) { - return res.status(422).json({ - error: true, - message: 'کاربر یافت نشد' - }) - } - const course = await CourseModel.findById(courseId) if (!course) { return res.status(422).json({ diff --git a/controllers/application/account/e2eeController.js b/controllers/application/account/e2eeController.js index 69ab0da..32e5a24 100644 --- a/controllers/application/account/e2eeController.js +++ b/controllers/application/account/e2eeController.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose') -const crypto = require('crypto') +const bcrypt = require('bcryptjs') const UserModel = require('../../../models/UserModel') const DeviceLinkSessionModel = require('../../../models/DeviceLinkSessionModel') @@ -154,34 +154,26 @@ const pollDeviceLink = async (req, res, next) => { } /** - * رمزِ خودکارِ per-account برای رمزنگاری/بازگشاییِ خودکارِ بک‌آپِ کلیدِ E2EE — به‌جای - * PIN دستیِ کاربر. با یه درخواستِ احرازهویت‌شده به همین اکانت (از هر دستگاهی) قابل‌ - * دریافته، پس بازیابیِ هویت روی دستگاهِ جدید بدون هیچ دخالتِ کاربری انجام می‌شه. - * اولین‌بار که خواسته بشه lazily ساخته می‌شه؛ با یه آپدیتِ شرطی (فقط اگه هنوز null - * باشه) از race بینِ دو دستگاهی که هم‌زمان اولین‌بار درخواستش می‌کنن جلوگیری می‌شه. + * کلیدِ E2EE حالا از رمزِ عبورِ خودِ کاربر مشتق می‌شه (نه یه رمزِ خودکارِ ساخته‌شده‌ی + * سرور) — این اندپوینت فقط رمزِ واردشده رو با bcrypt در برابرِ هشِ ذخیره‌شده تایید + * می‌کنه (عینِ loginWithUserName.js)، تا کلاینت قبل از اجرای PBKDF2ِ ۲۵۰هزار-تکراریِ + * محلی (برای decryptPrivateKeyFromBackup) یه فیدبکِ سریع برای «رمزِ اشتباه» داشته + * باشه؛ خودِ مشتق‌سازیِ کلید کاملاً سمتِ کلاینت انجام می‌شه، سرور هیچ‌وقت کلید رو نمی‌بینه */ -const getOrCreateAutoBackupSecret = async (req, res, next) => { +const verifyPassword = async (req, res, next) => { try { - const userId = req.user._id - const existing = await UserModel.findById(userId).select('e2ee_auto_backup_secret').lean() - if (existing?.e2ee_auto_backup_secret) { - return res.json({ secret: existing.e2ee_auto_backup_secret }) + const { password } = req.body + if (!password || typeof password !== 'string') { + return res.status(422).json({ error: 'password is required' }) } - const candidate = crypto.randomBytes(32).toString('base64') - const updated = await UserModel.findOneAndUpdate( - { _id: userId, e2ee_auto_backup_secret: null }, - { $set: { e2ee_auto_backup_secret: candidate } }, - { new: true } - ).select('e2ee_auto_backup_secret').lean() + const user = await UserModel.findById(req.user._id).select('password').lean() + if (!user?.password) { + return res.json({ valid: false }) + } - // اگه updated خالی برگشت یعنی یه درخواستِ هم‌زمانِ دیگه (دستگاهِ دیگه) بین - // خوندنِ بالا و همین آپدیت، خودش رمز رو ساخته بود — بخونش تا هر دو دستگاه دقیقاً - // یه رمزِ یکسان بگیرن، نه دوتا رمزِ متفاوت - const secret = updated?.e2ee_auto_backup_secret || - (await UserModel.findById(userId).select('e2ee_auto_backup_secret').lean())?.e2ee_auto_backup_secret - - res.json({ secret }) + const valid = await bcrypt.compare(password, user.password) + res.json({ valid }) } catch (error) { next(error) } @@ -243,7 +235,7 @@ module.exports = { getDeviceLinkSession, approveDeviceLink, pollDeviceLink, - getOrCreateAutoBackupSecret, + verifyPassword, putKeyBackup, getKeyBackup, deleteKeyBackup diff --git a/controllers/application/account/registerMobileController.js b/controllers/application/account/registerMobileController.js new file mode 100644 index 0000000..1ab6009 --- /dev/null +++ b/controllers/application/account/registerMobileController.js @@ -0,0 +1,129 @@ +const UserModel = require('../../../models/UserModel') +const { OTP_VALID_MS, isOtpExpired } = require('../../../utils/otpExpiry') +const { sendSmsIrVerify, getSmsIrTemplateId } = require('../../../utils/smsIr') + +function normalizeMobile (value) { + return String(value || '').replace(/\D/g, '') +} + +function generateOTP () { + return Math.floor(100000 + Math.random() * 900000) +} + +// کاربرانی که با گوگل/اینستاگرام ثبت‌نام کردن و شماره موبایل ندارن، از همین‌جا +// (نه فلوی لاگین) می‌تونن یه بار برای همیشه شماره‌شون رو ثبت کنن. بعد از ثبت، +// دیگه هیچ مسیرِ ویرایشی براش نیست — طبق خواسته‌ی کاربر. +const requestMobileOtp = async (req, res, next) => { + try { + if (req.user.mobile) { + return res.status(422).json({ + error: true, + message: 'شماره موبایل شما قبلاً ثبت شده و قابل تغییر نیست' + }) + } + + const mobile = normalizeMobile(req.body.mobile) + if (!mobile || !/^09[0-9]{9}$/.test(mobile)) { + return res.status(422).json({ + error: true, + message: 'فرمت شماره موبایل صحیح نیست' + }) + } + + const existing = await UserModel.findOne({ mobile }) + if (existing) { + return res.status(422).json({ + error: true, + message: 'این شماره موبایل قبلاً توسط کاربر دیگری ثبت شده است' + }) + } + + const otp = generateOTP() + const otpSentAt = new Date() + + await UserModel.updateOne( + { _id: req.user._id }, + { $set: { pending_mobile: mobile, otp: String(otp), otpSentAt } } + ) + + setTimeout(() => { + UserModel.updateOne( + { _id: req.user._id, otp: String(otp) }, + { $set: { otp: null, otpSentAt: null, pending_mobile: null } } + ).catch((error) => console.error('[requestMobileOtp] clear OTP failed:', error)) + }, OTP_VALID_MS) + + const smsResult = await sendSmsIrVerify({ + mobile, + otp, + templateId: getSmsIrTemplateId('930719') + }) + + if (!smsResult.ok) { + return res.status(502).json({ + error: true, + message: 'ارسال پیامک ناموفق بود. لطفاً کمی بعد دوباره تلاش کنید.' + }) + } + + return res.json({ success: true, message: 'کد تایید ارسال شد' }) + } catch (error) { + next(error) + } +} + +const verifyMobileOtp = async (req, res, next) => { + try { + const { otp } = req.body + if (!otp) { + return res.status(422).json({ error: true, message: 'کد تایید الزامی است' }) + } + + const user = await UserModel.findById(req.user._id) + if (!user.pending_mobile || !user.otp) { + return res.status(422).json({ + error: true, + message: 'درخواستی برای ثبت شماره موبایل یافت نشد. دوباره تلاش کنید' + }) + } + + if (isOtpExpired(user)) { + return res.status(422).json({ + error: true, + message: 'کد تایید منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید' + }) + } + + if (String(user.otp) !== String(otp)) { + return res.status(422).json({ error: true, message: 'کد را اشتباه وارد کردید' }) + } + + // بینِ درخواستِ کد و تاییدش، ممکنه یه کاربرِ دیگه دقیقاً همین شماره رو گرفته باشه + const conflict = await UserModel.findOne({ mobile: user.pending_mobile }) + if (conflict) { + return res.status(422).json({ + error: true, + message: 'این شماره موبایل قبلاً توسط کاربر دیگری ثبت شده است' + }) + } + + user.mobile = user.pending_mobile + user.pending_mobile = null + user.otp = null + user.otpSentAt = null + await user.save() + + return res.json({ + success: true, + message: 'شماره موبایل با موفقیت ثبت شد', + mobile: user.mobile + }) + } catch (error) { + next(error) + } +} + +module.exports = { + requestMobileOtp, + verifyMobileOtp +} diff --git a/controllers/application/chatRooms/chatRoomController.js b/controllers/application/chatRooms/chatRoomController.js index e6587fa..13cfaa1 100644 --- a/controllers/application/chatRooms/chatRoomController.js +++ b/controllers/application/chatRooms/chatRoomController.js @@ -21,6 +21,7 @@ const { resolveIdleTimeoutMs } = require('./chatRoomHelpers') const { opaquePreview } = require('../../../utils/e2ee') +const { needsUsernameAndPassword } = require('../../../utils/authSteps') const DEFAULT_ROOM = { title: 'چت روم مدستاگرام', @@ -395,6 +396,17 @@ const sendRoomMessage = async (req, res, next) => { }) } + // عینِ گیتِ /api/v1/chat (index.js) — بدونِ رمزِ عبور، کلیدِ E2EE نمی‌تونه + // بک‌آپ/بازیابی بشه، پس ارسالِ پیام (حتی توی روم) باید قبلش کاربرِ گوگل/ + // اینستاگرام‌ـی که هنوز رمز نساخته رو مسدود کنه + if (needsUsernameAndPassword(req.user)) { + return res.status(403).json({ + error: true, + type: 'needs_username_password', + message: 'برای ارسال پیام باید نام‌کاربری و رمز عبور خود را تکمیل کنید' + }) + } + let fileUrl = null if (uploaded) { const uploadDir = path.join(__dirname, '../../../storage/chat-rooms/messages') diff --git a/controllers/application/login/loginWithUserName.js b/controllers/application/login/loginWithUserName.js index f6f97dc..a6c16b0 100644 --- a/controllers/application/login/loginWithUserName.js +++ b/controllers/application/login/loginWithUserName.js @@ -6,7 +6,9 @@ const { expireBlockSuspensionIfNeeded, getBlockedAccountResponse } = require('../../../utils/blockSuspension') -const { buildLoginResponseForUser } = require('../../../utils/loginResponse') +const { signTemp2faToken } = require('../../../utils/loginResponse') +const { readTotpState } = require('../../../utils/totpStore') +const { buildAuthResponse } = require('../../../utils/authSteps') const INVALID_CREDENTIALS = 'نام کاربری یا کلمه عبور را اشتباه وارد کردید' @@ -49,9 +51,67 @@ const loginWithUserName = async (req, res, next) => { message: INVALID_CREDENTIALS }) } - // اگر هم نام کاربری و هم رمز عبور درست بود، ارسال پیام موفقیت آمیز + + // توکن همیشه با آیدیِ خودِ کاربرِ لاگین‌کننده ساخته می‌شه (چه مالکِ اصلی چه + // زیرمجموعه) — چون blockCheck/resolveActingUser هر درخواست رو از رویِ همین + // آیدی دوباره از دیتابیس می‌خونه و اگه زیرمجموعه بود، به اکانتِ اصلی resolve + // می‌کنه. اما خودِ ریسپانسِ لاگین (هویتی که کلاینت ذخیره می‌کنه) هم باید از + // همین‌جا به اکانتِ اصلی resolve بشه — وگرنه کلاینت فکر می‌کنه یه حسابِ کاملاً + // جدا و مجزاست (نه همون اکانتِ اصلی با دسترسیِ محدود) + let identityUser = user + let subUserFields = null + if (user.is_sub_user) { + if (user.sub_user_active === false) { + return res.status(403).json({ + error: true, + message: 'این حساب کاربری غیرفعال شده است' + }) + } + const parent = user.parent_account_id + ? await UserModel.findById(user.parent_account_id) + : null + if (!parent) { + return res.status(422).json({ + error: true, + message: INVALID_CREDENTIALS + }) + } + identityUser = parent + subUserFields = { + is_sub_user: true, + sub_user_permissions: user.sub_user_permissions || [] + } + } + const token = TokenService.sign({ id: user._id }) - return res.json(await buildLoginResponseForUser(user, token)) + + // ۲مرحله‌ای همیشه روی خودِ کاربرِ لاگین‌کننده چک می‌شه، نه روی اکانتِ اصلی — + // وگرنه اگه صاحبِ اکانت ۲مرحله‌ای رو فعال کنه، هر زیرمجموعه‌ای موقعِ لاگین با + // رمزِ خودش گیرِ کدِ ۲مرحله‌ایِ صاحبِ اکانت می‌افتاد که اصلاً بهش دسترسی نداره. + // زیرمجموعه‌ها هیچ UIای برای تنظیمِ ۲مرحله‌ایِ خودشون ندارن، پس این چک براشون + // همیشه false می‌مونه + try { + const totpState = await readTotpState(user._id) + if (totpState?.totp_enabled && totpState?.totp_secret) { + return res.json({ + requires_2fa: true, + temp_token: signTemp2faToken(user._id), + message: 'کد دو مرحله‌ای را وارد کنید' + }) + } + } catch (error) { + console.error('[loginWithUserName] totp check failed:', error) + } + + // buildAuthResponse مستقیم (نه buildLoginResponseForUser) چون اون دوباره خودش + // TOTP رو چک می‌کرد — این‌بار روی identityUser (اکانتِ اصلی)، که برای یه + // زیرمجموعه دقیقاً همون چکِ اشتباهی‌ای بود که بالا عمداً دورش زدیم + const response = buildAuthResponse(identityUser, token) + if (subUserFields) { + response.is_sub_user = subUserFields.is_sub_user + response.sub_user_permissions = subUserFields.sub_user_permissions + } + return res.json(response) } catch (error) { next(error) } diff --git a/controllers/application/payment/paymentControllerWeb.js b/controllers/application/payment/paymentControllerWeb.js index 4691fcd..b0d1c6f 100644 --- a/controllers/application/payment/paymentControllerWeb.js +++ b/controllers/application/payment/paymentControllerWeb.js @@ -17,6 +17,7 @@ const WalletTransactionModel = require('../../../models/WalletTransactionModel') const { PROJECT_ACCEPT_RATE, PROJECT_COMPLETION_COMMISSION_RATE, creditProjectShare } = require('../../../utils/projectCommission') const { isVerifiedBadge } = require('../../../utils/verifyBadge') const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit') +const { needsUsernameAndPassword } = require('../../../utils/authSteps') const OFFER_COMMISSION_RATE = 0.5 @@ -108,8 +109,8 @@ const initiatePaymentWeb = async (req, res) => { message: 'شما قبلاً از درخواست رایگان امروز استفاده کرده‌اید.' }) } - user.daily_free_request -= 1 - await user.save() + // updateOne + $inc به‌جای user.save() — رجوع کنید به توضیح مشابه در initiateOfferPaymentWeb + await UserModel.updateOne({ _id: userId }, { $inc: { daily_free_request: -1 } }) const project = await ProjectModel.findById(projectId) if (!project) { return res.status(404).json({ error: true, message: 'پروژه یافت نشد' }) @@ -573,7 +574,8 @@ const initiateAdvertisingPaymentWeb = async (req, res) => { if (item.name === 'free' && item.price === 0) { if (canUseFreeBillboard(user)) { await consumeFreeBillboard(user) - await user.save() + // updateOne + $inc به‌جای user.save() — رجوع کنید به توضیح مشابه در initiateOfferPaymentWeb + await UserModel.updateOne({ _id: userId }, { $inc: { free_billboard_count: 1 } }) const project = await AdvertisingModel.findById(advertisingId) if (!project) { res.status(404).send('Advertising not found') @@ -727,7 +729,8 @@ const republishAdvertisingPaymentWeb = async (req, res) => { if (item.name === 'free' && item.price === 0) { if (canUseFreeBillboard(user)) { await consumeFreeBillboard(user) - await user.save() + // updateOne + $inc به‌جای user.save() — رجوع کنید به توضیح مشابه در initiateOfferPaymentWeb + await UserModel.updateOne({ _id: userId }, { $inc: { free_billboard_count: 1 } }) const project = await AdvertisingModel.findById(advertisingId) if (!project) { res.status(404).send('Advertising not found') @@ -874,7 +877,13 @@ const initiateOfferPaymentWeb = async (req, res) => { const receiver = await UserModel.findById(receiverId) if (!user || !receiver) { return res.status(404).json({ error: 'User not found' }) } - const price = await getOfferPriceFromDatabase(offerType) // تومان + if (needsUsernameAndPassword(user)) { + return res.status(403).json({ + error: true, + type: 'needs_username_password', + message: 'برای ثبت درخواست همکاری باید نام‌کاربری و رمز عبور خود را تکمیل کنید' + }) + } // 🎁 free offer if (offerType === 'free' && user.monthly_free_offer > 0) { @@ -884,8 +893,10 @@ const initiateOfferPaymentWeb = async (req, res) => { transaction_id: null }) - user.monthly_free_offer -= 1 - await user.save() + // updateOne + $inc به‌جای user.save() — save() کل سند رو ری‌ولیدیت می‌کنه و برای + // کاربرهایی که مقدار قدیمی/نامعتبر توی فیلدهای دیگه (مثلاً income_range) دارن + // با ValidationError شکست می‌خوره، حتی وقتی فقط monthly_free_offer تغییر کرده + await UserModel.updateOne({ _id: userId }, { $inc: { monthly_free_offer: -1 } }) return res.json({ type: 'free', @@ -908,6 +919,11 @@ const initiateOfferPaymentWeb = async (req, res) => { }) } + // نوعِ «رایگان» قیمتی توی OfferTypeModel نداره (اصلاً پرایس‌دار نیست، سهمیه‌محوره) — + // پس این لوکاپ فقط باید برای نوع‌های واقعاً پولی اجرا بشه، بعد از هر دو حالتِ بالا؛ + // قبلاً بی‌قید-و-شرط بالای تابع اجرا می‌شد و برای «رایگان» با خطای «not found» throw می‌کرد + const price = await getOfferPriceFromDatabase(offerType) // تومان + const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(userId, price, useWallet) // کاملاً از کیف پول پوشش داده شد — اصلاً نیازی به درگاه نیست diff --git a/controllers/application/payment/shopOrderPaymentController.js b/controllers/application/payment/shopOrderPaymentController.js index a8eacd7..f99fd24 100644 --- a/controllers/application/payment/shopOrderPaymentController.js +++ b/controllers/application/payment/shopOrderPaymentController.js @@ -14,7 +14,7 @@ function toRial (tomanAmount) { const initiateOrderPayment = async (req, res) => { try { - const { orderId, useWallet } = req.body + const { orderId, useWallet, platform } = req.body if (!orderId) { return res.status(422).json({ error: true, message: 'سفارش مشخص نشده است' }) } @@ -71,13 +71,14 @@ const initiateOrderPayment = async (req, res) => { return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' }) } + const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : '' const response = await axios.post( 'https://api.zarinpal.com/pg/v4/payment/request.json', { merchant_id: MERCHANT_ID, amount: toRial(gatewayAmount), description: `پرداخت سفارش شماره ${order._id}`, - callback_url: `${callbackBase}/payment/shop-order/callback?orderId=${order._id}` + callback_url: `${callbackBase}/payment/shop-order/callback?orderId=${order._id}${platformSuffix}` }, { headers: { 'Content-Type': 'application/json' } } ) @@ -101,9 +102,18 @@ const initiateOrderPayment = async (req, res) => { } const handleOrderPaymentCallback = async (req, res) => { - const { orderId, Authority, Status } = req.query - const failRedirect = `${process.env.APP_SITE}/shops/payment/failed?orderId=${orderId || ''}` - const successRedirect = (id) => `${process.env.APP_SITE}/shops/payment/success?orderId=${id}` + const { orderId, Authority, Status, platform } = req.query + // اندروید: نتیجه با دیپ‌لینکِ اپ (modstagram://) برگردونده می‌شه، نه یه صفحه‌ی وب — + // عینِ همه‌ی مسیرهای پرداختِ دیگه (آفر/بیلبورد/پروژه). قبلاً این مسیر فقط به وب + // ریدایرکت می‌کرد، یعنی اپ هیچ‌وقت نمی‌فهمید پرداخت واقعاً موفق شده یا نه + const isAndroid = platform === 'android' + const failRedirect = isAndroid + ? `modstagram://FailedPay?type=shop&orderId=${orderId || ''}` + : `${process.env.APP_SITE}/shops/payment/failed?orderId=${orderId || ''}` + const successRedirect = (id) => + isAndroid + ? `modstagram://SuccessPay?type=shop&orderId=${id}` + : `${process.env.APP_SITE}/shops/payment/success?orderId=${id}` const io = req.app.get('io') diff --git a/controllers/application/posts/postController.js b/controllers/application/posts/postController.js index 1be3e25..e0fd2f5 100644 --- a/controllers/application/posts/postController.js +++ b/controllers/application/posts/postController.js @@ -6,11 +6,18 @@ const ProjectModel = require('../../../models/ProjectModel') const AdvertisingModel = require('../../../models/AdvertisingModel') const ShopModel = require('../../../models/ShopModel') const ShopProductListingModel = require('../../../models/ShopProductListingModel') +const LikeModel = require('../../../models/LikeModel') +const CommentModel = require('../../../models/CommentModel') +const PostReportModel = require('../../../models/PostReportModel') +const PostBookmarkModel = require('../../../models/PostBookmarkModel') +const PostShareModel = require('../../../models/PostShareModel') +const PostSendModel = require('../../../models/PostSendModel') const { CountryModel, ProvinceModel, CityModel } = require('../../../models/StateCity') const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity') const { check, validationResult } = require('express-validator') const jwt = require('jsonwebtoken') const path = require('path') +const fs = require('fs-extra') const { default: mongoose } = require('mongoose') const { viewerIsBlockedBy } = require('../../../utils/blockVisibility') const { attachSinglePostEngagement } = require('../../../utils/postEngagement') @@ -564,6 +571,56 @@ const getUserPostsWeb = async (req, res, next) => { } } +// حذفِ کاملِ پست — غیرقابل‌بازگشت. علاوه بر خودِ پست، هرچی که بهش ارجاع می‌ده هم پاک +// می‌شه (لایک/کامنت/گزارش/بوکمارک/اشتراک/ارسال‌شده‌درچت + پین‌شده از پروفایل مالک) تا +// دیتای یتیم تو دیتابیس نمونه؛ فایل‌های فیزیکی (عکس/ویدیو) هم از دیسک حذف می‌شن +const deletePost = async (req, res, next) => { + try { + const userId = req.user?._id || req.user?.id + if (!userId) { + return res.status(401).json({ error: true, message: 'توکن ارائه نشده است' }) + } + + const { postId } = req.params + if (!mongoose.Types.ObjectId.isValid(postId)) { + return res.status(400).json({ error: true, message: 'شناسه پست معتبر نیست' }) + } + + const post = await PostModel.findById(postId) + if (!post) { + return res.status(404).json({ error: true, message: 'پست یافت نشد' }) + } + if (String(post.user_id) !== String(userId)) { + return res.status(403).json({ error: true, message: 'اجازه‌ی حذف این پست را ندارید' }) + } + + await Promise.all([ + LikeModel.deleteMany({ postId }), + CommentModel.deleteMany({ post: postId }), + PostReportModel.deleteMany({ post_id: postId }), + PostBookmarkModel.deleteMany({ postId }), + PostShareModel.deleteMany({ postId }), + PostSendModel.deleteMany({ postId }), + UserModel.updateMany( + { pinned_post_ids: postId }, + { $pull: { pinned_post_ids: postId } } + ) + ]) + + for (const file of post.files || []) { + if (file?.path && fs.existsSync(file.path)) { + await fs.remove(file.path).catch(() => {}) + } + } + + await PostModel.deleteOne({ _id: postId }) + + return res.status(200).json({ message: 'پست با موفقیت حذف شد' }) + } catch (error) { + next(error) + } +} + const getPostByIdWeb = async (req, res, next) => { try { const { postId } = req.params @@ -659,6 +716,7 @@ module.exports = { createPostValidationRules, createPost, updatePost, + deletePost, getUserPosts, getUserPostsWeb, getPostByIdWeb diff --git a/controllers/application/register/completeSocialSignupController.js b/controllers/application/register/completeSocialSignupController.js new file mode 100644 index 0000000..3e51bc8 --- /dev/null +++ b/controllers/application/register/completeSocialSignupController.js @@ -0,0 +1,96 @@ +/* eslint-disable camelcase */ +const bcrypt = require('bcryptjs') +const { validateUsername } = require('../../../utils/usernameValidation') +const { validateStrongPassword } = require('../../../utils/strongPasswordValidation') +const { generateUsernameSuggestions } = require('../../../utils/usernameSuggestions') +const { isUsernameBlockedByTombstone } = require('../../../utils/accountLifecycle') +const { isSocialUser } = require('../../../utils/authSteps') +const UserModel = require('../../../models/UserModel') + +/** + * فقط برای کاربران گوگل/اینستاگرام که هنوز نام‌کاربری یا رمز عبور ندارند — + * قبل از اولین پیام یا ثبت درخواست همکاری، هر دو فیلد را یک‌جا (اتمیک، در یک + * درخواست) تکمیل می‌کند تا وضعیت نصفه‌کاره (نام‌کاربری ثبت‌شده ولی رمز خیر) + * پیش نیاید. + */ +const completeSocialSignup = async (req, res, next) => { + try { + const user = req.user + if (!user) { + return res.status(401).json({ error: true, message: 'کاربری یافت نشد' }) + } + + if (!isSocialUser(user)) { + return res.status(422).json({ + error: true, + message: 'این عملیات فقط برای کاربرانی است که با گوگل یا اینستاگرام وارد شده‌اند' + }) + } + + const { user_name, password } = req.body + + // اگر کاربر از قبل نام‌کاربری دارد، فقط رمز را تنظیم می‌کنیم؛ در غیر این + // صورت هر دو باید هم‌زمان معتبر باشند تا save() صدا زده شود (اتمیک). + const needsUsername = !user.user_name + const targetUserName = needsUsername ? user_name : user.user_name + + if (needsUsername) { + const usernameError = validateUsername(targetUserName) + if (usernameError) { + return res.status(422).json({ error: true, field: 'user_name', message: usernameError }) + } + } + + const passwordError = validateStrongPassword(password) + if (passwordError) { + return res.status(422).json({ error: true, field: 'password', message: passwordError }) + } + + let normalizedUserName + if (needsUsername) { + normalizedUserName = targetUserName.trim().toLowerCase() + + if (await isUsernameBlockedByTombstone(normalizedUserName)) { + return res.status(422).json({ + error: true, + field: 'user_name', + message: 'تا ۳۰ روز پس از حذف حساب، امکان استفاده از این نام کاربری وجود ندارد' + }) + } + + const existingUser = await UserModel.findOne({ + user_name: { $regex: new RegExp('^' + normalizedUserName + '$', 'i') } + }) + if (existingUser && existingUser._id.toString() !== user._id.toString()) { + const suggestions = await generateUsernameSuggestions( + UserModel, + normalizedUserName, + user._id.toString() + ) + return res.status(422).json({ + error: true, + field: 'user_name', + message: 'نام کاربری تکراری است', + suggestions + }) + } + } + + const hashedPassword = await bcrypt.hash(password, 10) + + if (needsUsername) { + user.user_name = normalizedUserName + } + user.password = hashedPassword + await user.save() + + return res.json({ + message: 'نام کاربری و رمز عبور با موفقیت ثبت شد', + user_name: user.user_name + }) + } catch (error) { + next(error) + } +} + +module.exports = { completeSocialSignup } diff --git a/controllers/application/stories/storyController.js b/controllers/application/stories/storyController.js index 9ca035e..6ab83bd 100644 --- a/controllers/application/stories/storyController.js +++ b/controllers/application/stories/storyController.js @@ -410,6 +410,13 @@ const getStoriesFeed = async (req, res, next) => { if (mine) myActiveStory = mine } + // طبق خواسته‌ی کاربر: نوار استوری فقط کاربرانی رو نشون بده که ویووَر فالو کرده — + // استوریِ خودِ کاربر جداگانه از my_story میاد (بالاتر استخراج شد)، پس اینجا از + // feed حذف می‌شه تا دوباره در قالب یه آیتمِ دیگه نشون داده نشه + feed = viewerId + ? feed.filter((f) => f.user._id !== viewerId && followingSet.has(f.user._id)) + : [] + // تبلیغِ نوع first_story — همیشه اولِ نوار، فقط برای کاربرِ واردشده (utils/adDelivery.js) if (viewerId) { try { diff --git a/controllers/application/subUsers/subUserController.js b/controllers/application/subUsers/subUserController.js new file mode 100644 index 0000000..4de5280 --- /dev/null +++ b/controllers/application/subUsers/subUserController.js @@ -0,0 +1,220 @@ +/* eslint-disable camelcase */ +const fs = require('fs-extra') +const path = require('path') +const bcrypt = require('bcryptjs') +const UserModel = require('../../../models/UserModel') +const { validateUsername } = require('../../../utils/usernameValidation') +const { optimizeImageFile } = require('../../../utils/imageOptimize') +const { sanitizeSubUserPermissions } = require('../../../utils/subUserPermissions') + +const AVATAR_MAX_DIMENSION = 800 +const PASSWORD_REGEX = /^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/u + +// یه کاربرِ زیرمجموعه فقط باید صاحبِ خودش رو ببینه/ویرایش کنه — نه سایرِ +// زیرمجموعه‌های حساب‌های دیگه، و خودِ یه زیرمجموعه هم اصلاً نباید بتونه این +// مسیرها رو صدا بزنه (چک با req.actingSubUser تو خودِ روت انجام می‌شه) +async function findOwnedSubUser (ownerId, subUserId) { + const subUser = await UserModel.findOne({ + _id: subUserId, + parent_account_id: ownerId, + is_sub_user: true + }) + return subUser +} + +async function saveAvatarIfProvided (user, files) { + const profile_image = files?.profile_image + if (!profile_image) return + const uploadDir = path.join(__dirname, '../../../storage/profiles') + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }) + } + const uniqueFileName = `${user.user_name}-${Date.now()}${path.extname(profile_image.name)}` + const filePath = path.join(uploadDir, uniqueFileName) + await fs.move(profile_image.path, filePath) + await optimizeImageFile(filePath, { maxDimension: AVATAR_MAX_DIMENSION }) + user.profile_image = `/profiles/${uniqueFileName}` +} + +const createSubUser = async (req, res, next) => { + try { + const { first_name, last_name, user_name, password } = req.body + let permissions = req.body.permissions + // فرم‌دیتا (چون همراه با آپلود عکسه) ممکنه permissions رو به‌صورت رشته‌ی JSON بفرسته + if (typeof permissions === 'string') { + try { permissions = JSON.parse(permissions) } catch { permissions = [] } + } + + if (!first_name || !first_name.trim()) { + return res.status(422).json({ error: true, message: 'نام الزامی است' }) + } + + const usernameError = validateUsername(user_name) + if (usernameError) { + return res.status(422).json({ error: true, message: usernameError }) + } + + if (!password || !PASSWORD_REGEX.test(password)) { + return res.status(422).json({ + error: true, + message: 'رمز عبور باید حداقل ۸ کاراکتر و شامل حروف و اعداد باشد' + }) + } + + const normalizedUsername = user_name.trim().toLowerCase() + const existing = await UserModel.findOne({ user_name: normalizedUsername }) + if (existing) { + return res.status(422).json({ error: true, message: 'این نام کاربری قبلاً استفاده شده است' }) + } + + const subUser = new UserModel({ + first_name: first_name.trim(), + last_name: (last_name || '').trim(), + user_name: normalizedUsername, + password: await bcrypt.hash(password, 10), + parent_account_id: req.user._id, + is_sub_user: true, + sub_user_active: true, + sub_user_permissions: sanitizeSubUserPermissions(permissions) + }) + + await saveAvatarIfProvided(subUser, req.files) + await subUser.save() + + return res.json({ + message: 'کاربر زیرمجموعه با موفقیت ساخته شد', + subUser: serializeSubUser(subUser) + }) + } catch (error) { + next(error) + } +} + +const listSubUsers = async (req, res, next) => { + try { + const subUsers = await UserModel.find({ + parent_account_id: req.user._id, + is_sub_user: true + }).sort({ createdAt: -1 }) + + return res.json({ subUsers: subUsers.map(serializeSubUser) }) + } catch (error) { + next(error) + } +} + +const getSubUser = async (req, res, next) => { + try { + const subUser = await findOwnedSubUser(req.user._id, req.params.id) + if (!subUser) { + return res.status(404).json({ error: true, message: 'کاربر زیرمجموعه یافت نشد' }) + } + return res.json({ subUser: serializeSubUser(subUser) }) + } catch (error) { + next(error) + } +} + +const updateSubUser = async (req, res, next) => { + try { + const subUser = await findOwnedSubUser(req.user._id, req.params.id) + if (!subUser) { + return res.status(404).json({ error: true, message: 'کاربر زیرمجموعه یافت نشد' }) + } + + const { first_name, last_name, user_name, password } = req.body + let permissions = req.body.permissions + if (typeof permissions === 'string') { + try { permissions = JSON.parse(permissions) } catch { permissions = undefined } + } + + if (first_name !== undefined) { + if (!first_name.trim()) { + return res.status(422).json({ error: true, message: 'نام الزامی است' }) + } + subUser.first_name = first_name.trim() + } + if (last_name !== undefined) { + subUser.last_name = last_name.trim() + } + + if (user_name !== undefined) { + const usernameError = validateUsername(user_name) + if (usernameError) { + return res.status(422).json({ error: true, message: usernameError }) + } + const normalizedUsername = user_name.trim().toLowerCase() + if (normalizedUsername !== subUser.user_name) { + const existing = await UserModel.findOne({ user_name: normalizedUsername }) + if (existing) { + return res.status(422).json({ error: true, message: 'این نام کاربری قبلاً استفاده شده است' }) + } + subUser.user_name = normalizedUsername + } + } + + if (password) { + if (!PASSWORD_REGEX.test(password)) { + return res.status(422).json({ + error: true, + message: 'رمز عبور باید حداقل ۸ کاراکتر و شامل حروف و اعداد باشد' + }) + } + subUser.password = await bcrypt.hash(password, 10) + } + + if (permissions !== undefined) { + subUser.sub_user_permissions = sanitizeSubUserPermissions(permissions) + } + + await saveAvatarIfProvided(subUser, req.files) + await subUser.save() + + return res.json({ + message: 'اطلاعات با موفقیت به‌روزرسانی شد', + subUser: serializeSubUser(subUser) + }) + } catch (error) { + next(error) + } +} + +const setSubUserActive = async (req, res, next) => { + try { + const subUser = await findOwnedSubUser(req.user._id, req.params.id) + if (!subUser) { + return res.status(404).json({ error: true, message: 'کاربر زیرمجموعه یافت نشد' }) + } + + subUser.sub_user_active = Boolean(req.body.active) + await subUser.save() + + return res.json({ + message: subUser.sub_user_active ? 'کاربر فعال شد' : 'کاربر غیرفعال شد', + subUser: serializeSubUser(subUser) + }) + } catch (error) { + next(error) + } +} + +function serializeSubUser (subUser) { + return { + _id: subUser._id, + first_name: subUser.first_name, + last_name: subUser.last_name, + user_name: subUser.user_name, + profile_image: subUser.profile_image, + sub_user_active: subUser.sub_user_active, + sub_user_permissions: subUser.sub_user_permissions || [], + createdAt: subUser.createdAt + } +} + +module.exports = { + createSubUser, + listSubUsers, + getSubUser, + updateSubUser, + setSubUserActive +} diff --git a/controllers/panel/users/usersController.js b/controllers/panel/users/usersController.js index ea555ad..5e19f1f 100644 --- a/controllers/panel/users/usersController.js +++ b/controllers/panel/users/usersController.js @@ -440,6 +440,12 @@ const grantGoldBadge = async (req, res, next) => { return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' }) } + if (user.is_verified !== 'verified') { + return res.status(422).json({ + message: 'ابتدا باید مدارک هویتی کاربر (تیک آبی) تایید شود' + }) + } + user.gold_badge = true syncVerifyBadge(user) await user.save() diff --git a/index.js b/index.js index abe6f53..c07e317 100644 --- a/index.js +++ b/index.js @@ -217,6 +217,7 @@ const { } = require('./helpers/expiredMessages') const { purgeInactiveChatRooms } = require('./controllers/application/chatRooms/chatRoomHelpers') const { trackOfferMessageForCommission } = require('./utils/offerReplyGate') +const { needsUsernameAndPassword } = require('./utils/authSteps') const { parseViewOnce, isViewOnceMediaType, @@ -302,6 +303,22 @@ app.post('/api/v1/chat', [blockCheck, chatPeerBlock], async (req, res) => { return res.status(422).json({ error: 'receiverId is required' }) } + if (needsUsernameAndPassword(req.user)) { + const alreadyChatting = await MessageModel.exists({ + $or: [ + { senderId, receiverId }, + { senderId: receiverId, receiverId: senderId } + ] + }) + if (!alreadyChatting) { + return res.status(403).json({ + error: true, + type: 'needs_username_password', + message: 'برای ارسال اولین پیام باید نام‌کاربری و رمز عبور خود را تکمیل کنید' + }) + } + } + const payload = { senderId, receiverId, @@ -412,6 +429,22 @@ app.post('/api/v1/chat/file', [blockCheck, chatPeerBlock], async (req, res) => { return res.status(422).json({ error: 'receiverId is required' }) } + if (needsUsernameAndPassword(req.user)) { + const alreadyChatting = await MessageModel.exists({ + $or: [ + { senderId, receiverId }, + { senderId: receiverId, receiverId: senderId } + ] + }) + if (!alreadyChatting) { + return res.status(403).json({ + error: true, + type: 'needs_username_password', + message: 'برای ارسال اولین پیام باید نام‌کاربری و رمز عبور خود را تکمیل کنید' + }) + } + } + if (viewOnceFlag && !isViewOnceMediaType(resolvedFileType)) { return res.status(422).json({ error: 'View once is only for image, video, and voice' }) } diff --git a/middlewares/blockCheck.js b/middlewares/blockCheck.js index 7a9683d..f63fad3 100644 --- a/middlewares/blockCheck.js +++ b/middlewares/blockCheck.js @@ -1,9 +1,9 @@ -const UserModel = require('../models/UserModel') const jwt = require('jsonwebtoken') const { expireBlockSuspensionIfNeeded, getBlockedAccountResponse } = require('../utils/blockSuspension') +const { resolveActingUser } = require('../utils/subUserAuth') module.exports = async (req, res, next) => { try { @@ -27,8 +27,15 @@ module.exports = async (req, res, next) => { }) } - let user = await UserModel.findById(decodedToken.id) - if (!user) { + const resolved = await resolveActingUser(decodedToken.id) + if (resolved.error === 'SUB_USER_DISABLED') { + return res.status(401).send({ + status: 'error', + code: 401, + message: 'این حساب کاربری غیرفعال شده است' + }) + } + if (resolved.error) { return res.status(401).send({ status: 'error', code: 401, @@ -36,6 +43,7 @@ module.exports = async (req, res, next) => { }) } + let user = resolved.user user = await expireBlockSuspensionIfNeeded(user) const blockResponse = getBlockedAccountResponse(user) @@ -44,6 +52,7 @@ module.exports = async (req, res, next) => { } req.user = user + req.actingSubUser = resolved.actingSubUser next() } catch (error) { console.error('Error in blockCheck middleware:', error) diff --git a/middlewares/index.js b/middlewares/index.js index ef293ae..9490255 100644 --- a/middlewares/index.js +++ b/middlewares/index.js @@ -1,5 +1,4 @@ const bodyParser = require('body-parser') -const formData = require('express-form-data') const session = require('express-session') const { RedisStore } = require('connect-redis') const { getRedisClient, getRawClient } = require('../utils/redisClient') @@ -49,7 +48,11 @@ function dispatchSession (req, res, next) { } module.exports = (app) => { - app.use(formData.parse()) + // express-form-data قبلاً اینجا global و بدون هیچ استثنایی نصب بود — بدنه‌ی هر + // درخواست multipart رو (پست/استوری ویدیو و هر آپلودِ دیگه) کامل می‌خوند و مصرف + // می‌کرد، درحالی‌که هیچ‌جای کدبیس از خروجیِ خودش استفاده نمی‌کرد؛ فقط باعث می‌شد + // وقتی نوبت به multer (همونی که این روت‌ها واقعاً استفاده می‌کنن) می‌رسید، دیگه + // هیچ بایتی از بدنه باقی نمونده باشه (busboy با «Unexpected end of form» شکست می‌خورد) app.use(bodyParser.json()) app.use(bodyParser.urlencoded({ extended: true })) app.use(dispatchSession) diff --git a/middlewares/isBasicRegister.js b/middlewares/isBasicRegister.js index e2eabdf..906f45c 100644 --- a/middlewares/isBasicRegister.js +++ b/middlewares/isBasicRegister.js @@ -1,9 +1,9 @@ -const UserModel = require('../models/UserModel') const jwt = require('jsonwebtoken') const { canUseProjects, respondProjectProfileIncomplete, } = require('../utils/projectProfile') +const { resolveActingUser } = require('../utils/subUserAuth') /** حداقل ثبت‌نام برای پروژه: نام کاربری + نام */ module.exports = async (req, res, next) => { @@ -28,8 +28,15 @@ module.exports = async (req, res, next) => { }) } - const user = await UserModel.findById(decodedToken.id) - if (!user) { + const resolved = await resolveActingUser(decodedToken.id) + if (resolved.error === 'SUB_USER_DISABLED') { + return res.status(401).send({ + status: 'error', + code: 401, + message: 'این حساب کاربری غیرفعال شده است', + }) + } + if (resolved.error) { return res.status(401).send({ status: 'error', code: 401, @@ -37,11 +44,12 @@ module.exports = async (req, res, next) => { }) } - if (!canUseProjects(user)) { + if (!canUseProjects(resolved.user)) { return respondProjectProfileIncomplete(res) } - req.user = user + req.user = resolved.user + req.actingSubUser = resolved.actingSubUser next() } catch (error) { console.error('isBasicRegister middleware:', error) diff --git a/middlewares/isHalfRegister.js b/middlewares/isHalfRegister.js index aaa6a47..4b97298 100644 --- a/middlewares/isHalfRegister.js +++ b/middlewares/isHalfRegister.js @@ -1,5 +1,5 @@ -const UserModel = require('../models/UserModel') // مسیر درست به مدل کاربر const jwt = require('jsonwebtoken') +const { resolveActingUser } = require('../utils/subUserAuth') module.exports = async (req, res, next) => { try { @@ -21,19 +21,28 @@ module.exports = async (req, res, next) => { message: 'Your token is not valid!' }) } - const user = await UserModel.findById(decodedToken.id) - if (!user) { + const resolved = await resolveActingUser(decodedToken.id) + if (resolved.error === 'SUB_USER_DISABLED') { + return res.status(401).send({ + status: 'error', + code: 401, + message: 'این حساب کاربری غیرفعال شده است' + }) + } + if (resolved.error) { return res.status(401).send({ status: 'error', code: 401, message: 'Your token is not valid!' }) } - if (!user.user_name || - !user.first_name || - !user.last_name || - !user.user_type || - !user.password) { + + const rawUser = resolved.rawUser + if (!rawUser.user_name || + !rawUser.first_name || + !rawUser.last_name || + (!rawUser.is_sub_user && !rawUser.user_type) || + !rawUser.password) { return res.status(403).send({ status: 'error', code: 403, @@ -41,7 +50,8 @@ module.exports = async (req, res, next) => { type: 'not_register' }) } - req.user = user // کاربر را به درخواست اضافه کنید تا در سایر middleware ها و کنترلرها استفاده شود. + req.user = resolved.user // کاربر را به درخواست اضافه کنید تا در سایر middleware ها و کنترلرها استفاده شود. + req.actingSubUser = resolved.actingSubUser next() } catch (error) { console.error('not_register middleware:', error) diff --git a/middlewares/isRegister.js b/middlewares/isRegister.js index 13bc9a2..7e70c04 100644 --- a/middlewares/isRegister.js +++ b/middlewares/isRegister.js @@ -1,5 +1,5 @@ -const UserModel = require('../models/UserModel') // مسیر درست به مدل کاربر const jwt = require('jsonwebtoken') +const { resolveActingUser } = require('../utils/subUserAuth') module.exports = async (req, res, next) => { try { @@ -23,18 +23,29 @@ module.exports = async (req, res, next) => { }) } - const user = await UserModel.findById(decodedToken.id) - if (!user) { + const resolved = await resolveActingUser(decodedToken.id) + if (resolved.error === 'SUB_USER_DISABLED') { + return res.status(401).send({ + status: 'error', + code: 401, + message: 'این حساب کاربری غیرفعال شده است' + }) + } + if (resolved.error) { return res.status(401).send({ status: 'error', code: 401, message: 'Your token is not valid!' }) } - if (!user.user_name || - !user.first_name || - !user.password || - !user.user_type) { + + // برای زیرمجموعه‌ها user_type معنا نداره (مالِ خودِ اکانتِ اصلیه، نه اونا) — + // فقط برای اکانتِ اصلی این فیلد رو چک می‌کنیم + const rawUser = resolved.rawUser + if (!rawUser.user_name || + !rawUser.first_name || + !rawUser.password || + (!rawUser.is_sub_user && !rawUser.user_type)) { return res.status(403).send({ status: 'error', code: 403, @@ -43,7 +54,8 @@ module.exports = async (req, res, next) => { }) } - req.user = user + req.user = resolved.user + req.actingSubUser = resolved.actingSubUser next() } catch (error) { console.error('not_register middleware:', error) diff --git a/middlewares/requireSubUserPermission.js b/middlewares/requireSubUserPermission.js new file mode 100644 index 0000000..8aecda3 --- /dev/null +++ b/middlewares/requireSubUserPermission.js @@ -0,0 +1,18 @@ +// مطابق middlewares/requirePermission.js (پنل ادمین) ولی برای زیرمجموعه‌های +// کاربرانِ عادی. باید بعد از میدل‌ویری که req.actingSubUser رو ست می‌کنه +// (blockCheck / isRegister / isHalfRegister / isBasicRegister) بیاد. +// اگه req.actingSubUser خالی باشه یعنی خودِ صاحبِ اکانته — دسترسیِ کامل داره. +module.exports = function requireSubUserPermission (key) { + return (req, res, next) => { + const actingSubUser = req.actingSubUser + if (!actingSubUser) return next() + if (Array.isArray(actingSubUser.permissions) && actingSubUser.permissions.includes(key)) { + return next() + } + return res.status(403).json({ + status: 'error', + code: 403, + message: 'شما دسترسی انجام این عملیات را ندارید' + }) + } +} diff --git a/models/UserModel.js b/models/UserModel.js index 956c423..d5da722 100644 --- a/models/UserModel.js +++ b/models/UserModel.js @@ -22,6 +22,15 @@ const userSchema = new mongoose.Schema({ required: false, default: null }, + // شماره‌ای که کاربرِ گوگلی/اینستاگرامیِ بدونِ موبایل، در حینِ فرایندِ تک‌مرحله‌ایِ + // «ثبت شماره موبایل» (تنظیمات) وارد کرده و منتظرِ تاییدِ OTP ـه — بعد از تایید، + // به mobile منتقل می‌شه و خودش null می‌شه + pending_mobile: { + type: String, + required: false, + trim: true, + default: null + }, email: { type: String, required: false, @@ -172,6 +181,25 @@ const userSchema = new mongoose.Schema({ enum: ['none', 'gray', 'blue', 'green', 'gold'], default: 'none' }, + // کاربرِ زیرمجموعه — یه سندِ UserModel معمولی که به‌جای اکانتِ خودش، اکانتِ + // parent_account_id رو مدیریت می‌کنه؛ فقط با پرمیژن‌های sub_user_permissions + parent_account_id: { + type: mongoose.Schema.Types.ObjectId, + ref: 'UserModel', + default: null + }, + is_sub_user: { + type: Boolean, + default: false + }, + sub_user_active: { + type: Boolean, + default: true + }, + sub_user_permissions: { + type: [String], + default: [] + }, profile_image: { type: String, required: false, @@ -803,17 +831,6 @@ const userSchema = new mongoose.Schema({ required: false, default: null }, - // یک رشته‌ی تصادفیِ ۳۲بایتیِ per-account که با درخواستِ احرازهویت‌شده به همین - // اکانت، از هر دستگاهی (وب/اندروید) قابل‌دریافته — به‌جای PIN دستی، به‌عنوانِ - // رمزِ رمزنگاریِ e2ee_key_backup استفاده می‌شه تا بازیابیِ هویتِ E2EE روی دستگاهِ - // جدید کاملاً خودکار و بدون دخالتِ کاربر انجام بشه. توجه: این یعنی تضمینِ - // end-to-end دیگه واقعی نیست — هرکسی که به این فیلد (یا سرور) دسترسی پیدا کنه - // می‌تونه بک‌آپ رو باز کنه؛ این تصمیم آگاهانه به نفعِ هیچ‌وقت‌گم‌نشدنِ پیام‌ها گرفته شده - e2ee_auto_backup_secret: { - type: String, - required: false, - default: null - }, pinned_post_ids: { type: [{ type: mongoose.Schema.Types.ObjectId, diff --git a/routes/application/academy/index.js b/routes/application/academy/index.js index 2ec6b8d..aaa96c9 100644 --- a/routes/application/academy/index.js +++ b/routes/application/academy/index.js @@ -5,6 +5,9 @@ const multer = require("multer"); const path = require("path"); const fs = require("fs"); const adminAuth = require("../../../middlewares/adminAuth"); +const auth = require("../../../middlewares/auth"); +const blockCheck = require("../../../middlewares/blockCheck"); +const requireSubUserPermission = require("../../../middlewares/requireSubUserPermission"); // const academyCategoryController = require("../../../controllers/application/academy/academyCategoryController"); // ========================================== @@ -79,10 +82,10 @@ router.post("/academy/profile", academyController.academyProfile); // ========================================== // ایجاد دوره جدید -router.post("/academy/creat/course", academyController.creatCourse); +router.post("/academy/creat/course", [auth, blockCheck, requireSubUserPermission('academy_packages')], academyController.creatCourse); // ویرایش دوره -router.patch("/academy/update/course", academyController.updateCourse); +router.patch("/academy/update/course", [auth, blockCheck, requireSubUserPermission('academy_packages')], academyController.updateCourse); // حذف دوره (تغییر وضعیت به reject) router.delete("/academy/delete/course", academyController.deleteCourse); diff --git a/routes/application/account/index.js b/routes/application/account/index.js index 2ae1433..9ec9831 100644 --- a/routes/application/account/index.js +++ b/routes/application/account/index.js @@ -1,6 +1,7 @@ const express = require('express') const auth = require('../../../middlewares/auth') const blockCheck = require('../../../middlewares/blockCheck') +const authRateLimit = require('../../../middlewares/authRateLimit') const { deactivateAccount, reactivateAccount, @@ -15,6 +16,10 @@ const { getArchivedPosts, getAccountAnalytics } = require('../../../controllers/application/profile/accountController') +const { + requestMobileOtp, + verifyMobileOtp +} = require('../../../controllers/application/account/registerMobileController') const { putMyPublicKey, getPublicKeyByUserId, @@ -22,7 +27,7 @@ const { getDeviceLinkSession, approveDeviceLink, pollDeviceLink, - getOrCreateAutoBackupSecret, + verifyPassword, putKeyBackup, getKeyBackup, deleteKeyBackup @@ -31,6 +36,8 @@ const { const router = express.Router() router.get('/status', auth, blockCheck, getAccountStatus) +router.post('/mobile/request', auth, blockCheck, requestMobileOtp) +router.post('/mobile/verify', auth, blockCheck, verifyMobileOtp) router.get('/visitors', auth, blockCheck, getProfileVisitors) router.patch('/privacy', auth, blockCheck, updatePrivacySettings) router.post('/reset-algorithm', auth, blockCheck, resetAlgorithm) @@ -48,7 +55,7 @@ router.post('/e2ee/device-link', auth, blockCheck, startDeviceLink) router.get('/e2ee/device-link/:linkId', auth, blockCheck, getDeviceLinkSession) router.post('/e2ee/device-link/:linkId/approve', auth, blockCheck, approveDeviceLink) router.get('/e2ee/device-link/:linkId/poll', auth, blockCheck, pollDeviceLink) -router.get('/e2ee/auto-backup-secret', auth, blockCheck, getOrCreateAutoBackupSecret) +router.post('/e2ee/verify-password', auth, blockCheck, authRateLimit, verifyPassword) router.put('/e2ee/key-backup', auth, blockCheck, putKeyBackup) router.get('/e2ee/key-backup', auth, blockCheck, getKeyBackup) router.delete('/e2ee/key-backup', auth, blockCheck, deleteKeyBackup) diff --git a/routes/application/advertising/index.js b/routes/application/advertising/index.js index 7e9daa4..2c8f8c4 100644 --- a/routes/application/advertising/index.js +++ b/routes/application/advertising/index.js @@ -2,6 +2,7 @@ const express = require('express') const blockCheck = require('../../../middlewares/blockCheck') const { createAdvertisingValidationRules, createAdvertising, getAdvertisingTypes, getAdvertisingCategory, getSingleAdvertising, getAdvertisingFeatures, getAdvertisings, toggleLike, addComment, getComments, rateAdvertising, getUserAdvertisings, getAdvertisingAnalytics, editAdvertising, getUserSingleAdvertising, getUserAdvertisingProfile, updateUserAdvertisingProfile, getAdvertisingsWeb, getSingleAdvertisingWeb, getUserAdvertisingProfileWeb, recordAdvertisingView, getUserLikedAdvertisings, getUserAdvertisingComments } = require('../../../controllers/application/advertising/advertisingController') const auth = require('../../../middlewares/auth') +const requireSubUserPermission = require('../../../middlewares/requireSubUserPermission') const { initiateAdvertisingPayment, handleAdvertisingPaymentCallback, handleAdvertisingRepublishPaymentCallback, republishAdvertisingPayment } = require('../../../controllers/application/payment/paymentController') const { initiateAdvertisingPaymentWeb, handleAdvertisingPaymentCallbackWeb, republishAdvertisingPaymentWeb, handleAdvertisingRepublishPaymentCallbackWeb } = require('../../../controllers/application/payment/paymentControllerWeb') const router = express.Router() @@ -14,7 +15,7 @@ router.get('/user-advertising', [auth], getUserAdvertisings) router.get('/analytics', [auth], getAdvertisingAnalytics) router.get('/user-liked', [auth], getUserLikedAdvertisings) router.get('/user-comments', [auth], getUserAdvertisingComments) -router.post('/create', [auth], [blockCheck], createAdvertisingValidationRules(), createAdvertising) +router.post('/create', [auth], [blockCheck], requireSubUserPermission('billboards'), createAdvertisingValidationRules(), createAdvertising) router.get('/types', [auth], getAdvertisingTypes) router.get('/categories', getAdvertisingCategory) router.get('/features', [auth], getAdvertisingFeatures) @@ -34,6 +35,6 @@ router.post('/like', [blockCheck], [auth], toggleLike) router.post('/comment', [blockCheck], [auth], addComment) router.get('/comments', getComments) router.post('/rate', [blockCheck], [auth], rateAdvertising) -router.post('/edit/:id', [blockCheck], [auth], editAdvertising) +router.post('/edit/:id', [blockCheck], [auth], requireSubUserPermission('billboards'), editAdvertising) module.exports = router diff --git a/routes/application/bookings/index.js b/routes/application/bookings/index.js index 7aeba89..762383f 100644 --- a/routes/application/bookings/index.js +++ b/routes/application/bookings/index.js @@ -21,11 +21,12 @@ const { handleBookingPaymentCallback } = require('../../../controllers/application/bookings/bookingPaymentController') const blockCheck = require('../../../middlewares/blockCheck') +const requireSubUserPermission = require('../../../middlewares/requireSubUserPermission') const router = express.Router() // --- Booking-config wizard (provider setup) --- -router.post('/draft', [blockCheck], createDraftBookingConfig) +router.post('/draft', [blockCheck], requireSubUserPermission('bookings'), createDraftBookingConfig) router.get('/mine', [blockCheck], getMyBookingConfigs) // --- Reservations (buyer booking + provider's received bookings) --- @@ -42,9 +43,9 @@ router.get('/payment-callback/web', handleBookingPaymentCallback) // --- Booking-config wizard (continued) --- router.get('/:id', [blockCheck], getBookingConfigById) -router.patch('/:id/services', [blockCheck], updateBookingConfigServices) -router.patch('/:id/schedule', [blockCheck], updateBookingConfigSchedule) -router.patch('/:id/status', [blockCheck], updateBookingConfigStatus) -router.post('/:id/submit', [blockCheck], submitBookingConfig) +router.patch('/:id/services', [blockCheck], requireSubUserPermission('bookings'), updateBookingConfigServices) +router.patch('/:id/schedule', [blockCheck], requireSubUserPermission('bookings'), updateBookingConfigSchedule) +router.patch('/:id/status', [blockCheck], requireSubUserPermission('bookings'), updateBookingConfigStatus) +router.post('/:id/submit', [blockCheck], requireSubUserPermission('bookings'), submitBookingConfig) module.exports = router diff --git a/routes/application/chat-rooms/index.js b/routes/application/chat-rooms/index.js index eb96f7c..568d598 100644 --- a/routes/application/chat-rooms/index.js +++ b/routes/application/chat-rooms/index.js @@ -11,6 +11,7 @@ const { getRoomE2eeKeys, putRoomE2eeKeys } = require('../../../controllers/application/chatRooms/chatRoomController') +const requireSubUserPermission = require('../../../middlewares/requireSubUserPermission') const router = express.Router() @@ -22,7 +23,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', sendRoomMessage) +router.post('/:roomId/messages', requireSubUserPermission('reply_dm'), sendRoomMessage) router.post('/:roomId/messages/reaction', reactToRoomMessage) module.exports = router diff --git a/routes/application/orders/index.js b/routes/application/orders/index.js index 52762e8..085cfbd 100644 --- a/routes/application/orders/index.js +++ b/routes/application/orders/index.js @@ -11,14 +11,15 @@ const { reportOrder, createReturnRequest } = require('../../../controllers/application/orders/orderController') +const requireSubUserPermission = require('../../../middlewares/requireSubUserPermission') const router = express.Router() router.post('/draft', createOrderDraft) router.get('/', getOrders) router.get('/:id', getOrderById) -router.patch('/:id/status', updateOrderStatus) -router.patch('/:id/tracking-code', updateTrackingCode) +router.patch('/:id/status', requireSubUserPermission('order_status'), updateOrderStatus) +router.patch('/:id/tracking-code', requireSubUserPermission('order_status'), updateTrackingCode) router.patch('/:id/confirm-receipt', confirmReceipt) router.patch('/:id/cancel', cancelOrder) router.post('/:id/rate', rateOrder) diff --git a/routes/application/posts/index.js b/routes/application/posts/index.js index d489298..9119fb7 100644 --- a/routes/application/posts/index.js +++ b/routes/application/posts/index.js @@ -1,5 +1,5 @@ const express = require('express'); -const { createPostValidationRules, createPost, updatePost, getUserPosts, getUserPostsWeb, getPostByIdWeb } = require('../../../controllers/application/posts/postController'); +const { createPostValidationRules, createPost, updatePost, deletePost, getUserPosts, getUserPostsWeb, getPostByIdWeb } = require('../../../controllers/application/posts/postController'); const { toggleLike } = require('../../../controllers/application/posts/likeController'); const { toggleBookmark, @@ -15,6 +15,7 @@ const { reportPost } = require('../../../controllers/application/posts/reportCon const blockCheck = require('../../../middlewares/blockCheck'); const auth = require('../../../middlewares/auth'); const requirePostExpertise = require('../../../middlewares/requirePostExpertise'); +const requireSubUserPermission = require('../../../middlewares/requireSubUserPermission'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); @@ -121,6 +122,7 @@ router.post( next(); }, blockCheck, + requireSubUserPermission('posts_stories'), (req, res, next) => { console.log('DEBUG: reached requirePostExpertise'); next(); @@ -153,6 +155,7 @@ router.post( '/create-base64', auth, blockCheck, + requireSubUserPermission('posts_stories'), requirePostExpertise, createPostValidationRules(), async (req, res) => { @@ -254,6 +257,7 @@ async function handleUpdateBase64(req, res) { } } +router.delete('/:postId', auth, blockCheck, deletePost); router.post('/update-base64', auth, blockCheck, handleUpdateBase64); // مسیر جایگزین برای سرورهایی که روت قبلی را کش کرده‌اند router.put('/update-base64', auth, blockCheck, handleUpdateBase64); diff --git a/routes/application/projects/index.js b/routes/application/projects/index.js index 353e090..d887788 100644 --- a/routes/application/projects/index.js +++ b/routes/application/projects/index.js @@ -8,6 +8,7 @@ const { requestProjectValidationRules, requestProject, acceptProject, editReques const { editProject, doneProject, cancleProject, editProjectValidationRules, setRateProject, doneProjectWeb } = require('../../../controllers/application/projects/updateProjectController') const blockCheck = require('../../../middlewares/blockCheck') const isBasicRegister = require('../../../middlewares/isBasicRegister') +const requireSubUserPermission = require('../../../middlewares/requireSubUserPermission') const { initiatePaymentWeb, handlePaymentCallbackWeb, @@ -25,8 +26,8 @@ router.get('/get/:projectId', [auth], getSingleProjects) router.get('/get/web/:projectId', getSingleProjectsWeb) router.get('/payment', handlePaymentCallback) router.get('/payment-web', handlePaymentCallbackWeb) -router.post('/create', [blockCheck], [auth], createProjectValidationRules(), createProject) -router.post('/edit', [blockCheck], [auth], editProjectValidationRules(), editProject) +router.post('/create', [blockCheck], [auth], requireSubUserPermission('projects'), createProjectValidationRules(), createProject) +router.post('/edit', [blockCheck], [auth], requireSubUserPermission('projects'), editProjectValidationRules(), editProject) router.post('/rate', [blockCheck], [auth], [isBasicRegister], setRateProject) router.post('/initiate-payment', [blockCheck], [isBasicRegister], [auth], initiatePayment) router.post('/initiate-payment-web', [blockCheck], [isBasicRegister], [auth], initiatePaymentWeb) @@ -41,9 +42,9 @@ router.get('/payment-request-step-one-callback/web', paymentRequestAcceptWebCall router.get('/payment-request-step-two-callback', paymentRequestStepTwoCallback) router.get('/payment-request-step-all-callback', paymentRequestStepAllCallback) router.post('/accept', [isBasicRegister], [blockCheck], [auth], acceptProject) -router.post('/done', [isBasicRegister], [auth], doneProject) -router.post('/done/web', [isBasicRegister], [auth], doneProjectWeb) -router.post('/cancle', [isBasicRegister], [blockCheck], [auth], cancleProject) +router.post('/done', [isBasicRegister], [auth], requireSubUserPermission('projects'), doneProject) +router.post('/done/web', [isBasicRegister], [auth], requireSubUserPermission('projects'), doneProjectWeb) +router.post('/cancle', [isBasicRegister], [blockCheck], [auth], requireSubUserPermission('projects'), cancleProject) router.post('/initiate-completion-payment/web', [isBasicRegister], [blockCheck], [auth], initiateProjectCompletionPaymentWeb) router.get('/payment-completion-callback/web', handleProjectCompletionPaymentCallbackWeb) diff --git a/routes/application/register/index.js b/routes/application/register/index.js index 181c743..b9edaa7 100644 --- a/routes/application/register/index.js +++ b/routes/application/register/index.js @@ -7,8 +7,10 @@ const { setUserFullName, setUserFullNameValidationRules } = require('../../../co const { setUserType } = require('../../../controllers/application/register/userTypeController') const { setRegisterPassword, setRegisterPasswordValidationRules } = require('../../../controllers/application/register/passwordController') const { getRegisterStatus } = require('../../../controllers/application/register/statusController') +const { completeSocialSignup } = require('../../../controllers/application/register/completeSocialSignupController') const auth = require('../../../middlewares/auth') const authRateLimit = require('../../../middlewares/authRateLimit') +const blockCheck = require('../../../middlewares/blockCheck') router.post('/', authRateLimit, registerValidationRules(), registerUser) router.post('/verify', authRateLimit, verifyUser) @@ -18,5 +20,6 @@ router.patch('/username', updateUserName) router.post('/password', setRegisterPasswordValidationRules(), setRegisterPassword) router.post('/fullname', setUserFullNameValidationRules(), setUserFullName) router.post('/user_type', setUserType) +router.post('/complete-social-signup', [blockCheck], completeSocialSignup) module.exports = router diff --git a/routes/application/shop-products/index.js b/routes/application/shop-products/index.js index d2ec440..0ba4074 100644 --- a/routes/application/shop-products/index.js +++ b/routes/application/shop-products/index.js @@ -1,5 +1,6 @@ const express = require('express') const blockCheck = require('../../../middlewares/blockCheck') +const requireSubUserPermission = require('../../../middlewares/requireSubUserPermission') const { searchCatalog, createShopProduct, @@ -24,8 +25,8 @@ router.get('/:id', getShopProductById) router.get('/:id/comments', getShopProductComments) // Seller-only -router.post('/', [blockCheck], createShopProduct) -router.patch('/:id', [blockCheck], updateShopProduct) +router.post('/', [blockCheck], requireSubUserPermission('shop_products'), createShopProduct) +router.patch('/:id', [blockCheck], requireSubUserPermission('shop_products'), updateShopProduct) // Authenticated buyer actions router.post('/:id/comments', [blockCheck], createShopProductComment) diff --git a/routes/application/shops/index.js b/routes/application/shops/index.js index f98e752..186b100 100644 --- a/routes/application/shops/index.js +++ b/routes/application/shops/index.js @@ -25,10 +25,11 @@ const { toggleFavorite } = require('../../../controllers/application/shops/favoriteController') const blockCheck = require('../../../middlewares/blockCheck') +const requireSubUserPermission = require('../../../middlewares/requireSubUserPermission') const router = express.Router() -router.post('/draft', [blockCheck], createDraftShop) +router.post('/draft', [blockCheck], requireSubUserPermission('create_shop'), createDraftShop) router.get('/mine', [blockCheck], getMyShops) router.get('/analytics', [blockCheck], getShopAnalytics) @@ -42,13 +43,13 @@ router.post('/favorites/toggle', [blockCheck], toggleFavorite) router.get('/public/:id', getShopPublic) router.get('/:id', [blockCheck], getShopById) -router.patch('/:id/basic', [blockCheck], updateShopBasic) -router.patch('/:id/logo', [blockCheck], updateShopLogo) -router.patch('/:id/shipping', [blockCheck], updateShopShipping) -router.patch('/:id/location', [blockCheck], updateShopLocation) -router.patch('/:id/contact', [blockCheck], updateShopContact) +router.patch('/:id/basic', [blockCheck], requireSubUserPermission('create_shop'), updateShopBasic) +router.patch('/:id/logo', [blockCheck], requireSubUserPermission('create_shop'), updateShopLogo) +router.patch('/:id/shipping', [blockCheck], requireSubUserPermission('create_shop'), updateShopShipping) +router.patch('/:id/location', [blockCheck], requireSubUserPermission('create_shop'), updateShopLocation) +router.patch('/:id/contact', [blockCheck], requireSubUserPermission('create_shop'), updateShopContact) router.post('/:id/follow', [blockCheck], toggleShopFollow) -router.post('/:id/submit', [blockCheck], submitShop) +router.post('/:id/submit', [blockCheck], requireSubUserPermission('create_shop'), submitShop) router.post('/:id/send', [blockCheck], sendShopToUsers) module.exports = router diff --git a/routes/application/stories/index.js b/routes/application/stories/index.js index 38356ff..ac08a00 100644 --- a/routes/application/stories/index.js +++ b/routes/application/stories/index.js @@ -5,6 +5,7 @@ const multer = require('multer') const auth = require('../../../middlewares/auth') const blockCheck = require('../../../middlewares/blockCheck') const isHalfRegister = require('../../../middlewares/isHalfRegister') +const requireSubUserPermission = require('../../../middlewares/requireSubUserPermission') const { applyPhotoFilter, createStoryBase64, @@ -45,7 +46,7 @@ const storyUpload = multer({ router.get('/feed', getStoriesFeed) router.post('/apply-filter', auth, blockCheck, isHalfRegister, applyPhotoFilter) -router.post('/create-base64', auth, blockCheck, isHalfRegister, createStoryBase64) +router.post('/create-base64', auth, blockCheck, isHalfRegister, requireSubUserPermission('posts_stories'), createStoryBase64) // عین /posts/create چندبخشی — برای فایل‌های حجیم (مخصوصاً ویدیو) که تبدیل به base64 توی // حافظه‌ی JS موبایل باعث خطای OOM می‌شد؛ فایل مستقیم از دیسک استریم می‌شه router.post( @@ -53,6 +54,7 @@ router.post( auth, blockCheck, isHalfRegister, + requireSubUserPermission('posts_stories'), (req, res, next) => { storyUpload(req, res, (err) => { if (err) return res.status(400).json({ error: true, message: err.message }) diff --git a/routes/application/sub-users/index.js b/routes/application/sub-users/index.js new file mode 100644 index 0000000..8ca5cbc --- /dev/null +++ b/routes/application/sub-users/index.js @@ -0,0 +1,32 @@ +const express = require('express') +const router = express.Router() + +const auth = require('../../../middlewares/auth') +const blockCheck = require('../../../middlewares/blockCheck') +const { + createSubUser, + listSubUsers, + getSubUser, + updateSubUser, + setSubUserActive +} = require('../../../controllers/application/subUsers/subUserController') + +// یه کاربرِ زیرمجموعه هیچ‌وقت نباید بتونه خودش زیرمجموعه بسازه/مدیریت کنه — +// فقط صاحبِ اصلیِ اکانت (req.actingSubUser خالیه) +function ownerOnly (req, res, next) { + if (req.actingSubUser) { + return res.status(403).json({ + error: true, + message: 'کاربر زیرمجموعه اجازه‌ی مدیریت کاربران زیرمجموعه را ندارد' + }) + } + next() +} + +router.post('/', [auth, blockCheck, ownerOnly], createSubUser) +router.get('/', [auth, blockCheck, ownerOnly], listSubUsers) +router.get('/:id', [auth, blockCheck, ownerOnly], getSubUser) +router.patch('/:id', [auth, blockCheck, ownerOnly], updateSubUser) +router.patch('/:id/active', [auth, blockCheck, ownerOnly], setSubUserActive) + +module.exports = router diff --git a/routes/application/users/index.js b/routes/application/users/index.js index 988f923..1d2d25f 100644 --- a/routes/application/users/index.js +++ b/routes/application/users/index.js @@ -12,6 +12,7 @@ const { const blockCheck = require('../../../middlewares/blockCheck') const isRegister = require('../../../middlewares/isRegister') const auth = require('../../../middlewares/auth') +const requireSubUserPermission = require('../../../middlewares/requireSubUserPermission') const router = express.Router() router.get('/follow/stats', auth, getFollowStats) router.get('/follow/followers', auth, getFollowers) @@ -31,7 +32,7 @@ router.get('/get/web', getSingleUserWeb) router.post('/block', [auth], [isRegister], [blockCheck], blockUser) router.post('/unblock', [auth], [isRegister], [blockCheck], unblockUser) router.get('/comments', [auth], getComments) -router.post('/comments', [auth], [isRegister], [blockCheck], createUserComment) +router.post('/comments', [auth], [isRegister], [blockCheck], requireSubUserPermission('reply_comments'), createUserComment) router.post('/comments/like', [auth], [isRegister], [blockCheck], toggleCommentLike) router.post('/explore/interaction', [auth], recordExploreInteraction) router.get('/invite-employer', [auth], [isRegister], [blockCheck], inviteEmployer) diff --git a/routes/index.js b/routes/index.js index 2ae9d3d..4691642 100644 --- a/routes/index.js +++ b/routes/index.js @@ -38,6 +38,7 @@ const accountRouter = require('./application/account') const giftsRouter = require('./application/gifts') const chatRoomsRouter = require('./application/chat-rooms') const suggestionsRouter = require('./application/suggestions') +const subUsersRouter = require('./application/sub-users') const analyticsRouter = require('./application/analytics') const geoRouter = require('./application/geo') @@ -117,6 +118,7 @@ module.exports = (app) => { app.use('/api/v1/geo', geoRouter) app.use('/api/v1/academy', academyRoute) app.use('/api/v1/stories', storiesRouter) + app.use('/api/v1/sub-users', subUsersRouter) // Panel // فقط متدهای نوشتاری (POST/PUT/PATCH/DELETE) رو خودکار توی AuditLog ثبت می‌کنه — diff --git a/storage/.gitkeep b/storage/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/utils/authSteps.js b/utils/authSteps.js index 6f36c7b..d1434e3 100644 --- a/utils/authSteps.js +++ b/utils/authSteps.js @@ -12,6 +12,11 @@ function isSocialUser (user) { } function isBasicRegistrationComplete (user) { + // کاربرِ زیرمجموعه همون لحظه‌ی ساخت توسطِ صاحبِ اکانت کامل ساخته می‌شه (نام، + // نام‌کاربری، رمز) — user_type براش معنا نداره، پس نباید وارد ویزاردِ ثبت‌نام بشه + if (user?.is_sub_user) { + return Boolean(user?.user_name && user?.password && user?.first_name) + } const hasPassword = isSocialUser(user) || Boolean(user?.password) return Boolean( user?.user_name && @@ -22,6 +27,7 @@ function isBasicRegistrationComplete (user) { } function getRegistrationStep (user) { + if (user?.is_sub_user) return 'complete' if (!user?.user_name) return 'user_name' if (!isSocialUser(user) && !user.password) return 'password' if (!user.first_name) return 'first_name' @@ -33,6 +39,14 @@ function isRegistrationIncomplete (user) { return !isBasicRegistrationComplete(user) } +/** + * کاربر گوگلی/اینستاگرامی که هنوز نام‌کاربری یا رمز عبور ندارد — قبل از اولین + * پیام یا ثبت درخواست همکاری باید تکمیلش کند (مودال ست‌کردن نام‌کاربری+رمز). + */ +function needsUsernameAndPassword (user) { + return isSocialUser(user) && (!user?.user_name || !user?.password) +} + function buildAuthResponse (user, token) { const step = getRegistrationStep(user) const incomplete = isRegistrationIncomplete(user) @@ -49,6 +63,8 @@ function buildAuthResponse (user, token) { page: incomplete ? 'auth-page' : 'home', user_type: incomplete ? null : user.user_type, registrationComplete: !incomplete, + is_sub_user: Boolean(user.is_sub_user), + sub_user_permissions: user.is_sub_user ? (user.sub_user_permissions || []) : undefined, } } @@ -59,5 +75,6 @@ module.exports = { isBasicRegistrationComplete, getRegistrationStep, isRegistrationIncomplete, + needsUsernameAndPassword, buildAuthResponse } diff --git a/utils/strongPasswordValidation.js b/utils/strongPasswordValidation.js new file mode 100644 index 0000000..897b05a --- /dev/null +++ b/utils/strongPasswordValidation.js @@ -0,0 +1,34 @@ +/** + * این پسورد پایه‌ی رمزنگاری E2EE هم هست (کلید با آن wrap می‌شود)، برای همین + * قانون قوی‌تری از پسورد معمولیِ ثبت‌نام/تغییر رمز (که فقط حرف+عدد می‌خواهد) دارد. + */ +const STRONG_PASSWORD_MIN_LENGTH = 8 +const STRONG_PASSWORD_MAX_LENGTH = 64 +const STRONG_PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,64}$/ + +const validateStrongPassword = (password) => { + if (!password || typeof password !== 'string') { + return 'رمز عبور الزامی است' + } + + if (password.length < STRONG_PASSWORD_MIN_LENGTH) { + return `رمز عبور باید حداقل ${STRONG_PASSWORD_MIN_LENGTH} کاراکتر باشد` + } + + if (password.length > STRONG_PASSWORD_MAX_LENGTH) { + return `رمز عبور نمی‌تواند بیشتر از ${STRONG_PASSWORD_MAX_LENGTH} کاراکتر باشد` + } + + if (!STRONG_PASSWORD_REGEX.test(password)) { + return 'رمز عبور باید شامل حروف بزرگ، حروف کوچک، عدد و یک کاراکتر خاص باشد' + } + + return null +} + +module.exports = { + STRONG_PASSWORD_REGEX, + STRONG_PASSWORD_MIN_LENGTH, + STRONG_PASSWORD_MAX_LENGTH, + validateStrongPassword +} diff --git a/utils/subUserAuth.js b/utils/subUserAuth.js new file mode 100644 index 0000000..46cf10b --- /dev/null +++ b/utils/subUserAuth.js @@ -0,0 +1,40 @@ +const UserModel = require('../models/UserModel') + +// وقتی توکن مالِ یه کاربرِ زیرمجموعه‌ست (is_sub_user)، req.user باید اکانتِ اصلی +// (parent_account_id) باشه — نه خودِ سندِ زیرمجموعه — تا همه‌ی کنترلرهای فعلی +// (پست/کامنت/فروشگاه/...) بدونِ هیچ تغییری محتوا رو به اکانتِ اصلی نسبت بدن. +// req.actingSubUser فقط برای چکِ پرمیژن (requireSubUserPermission) استفاده می‌شه. +// چون این لوکاپ هر بار از دیتابیس تازه انجام می‌شه (نه از خودِ JWT)، غیرفعال‌کردنِ +// یه زیرمجموعه بلافاصله از درخواستِ بعدیش جلوگیری می‌کنه. +async function resolveActingUser (userId) { + const loaded = await UserModel.findById(userId) + if (!loaded) { + return { error: 'NOT_FOUND' } + } + + if (!loaded.is_sub_user) { + return { user: loaded, actingSubUser: null, rawUser: loaded } + } + + if (loaded.sub_user_active === false) { + return { error: 'SUB_USER_DISABLED' } + } + + const parent = loaded.parent_account_id + ? await UserModel.findById(loaded.parent_account_id) + : null + if (!parent) { + return { error: 'NOT_FOUND' } + } + + return { + user: parent, + rawUser: loaded, + actingSubUser: { + _id: loaded._id, + permissions: loaded.sub_user_permissions || [] + } + } +} + +module.exports = { resolveActingUser } diff --git a/utils/subUserPermissions.js b/utils/subUserPermissions.js new file mode 100644 index 0000000..6870dfe --- /dev/null +++ b/utils/subUserPermissions.js @@ -0,0 +1,27 @@ +// کاتالوگِ پرمیژن‌هایی که صاحبِ اکانت می‌تونه به کاربرِ زیرمجموعه بده — مطابقِ +// utils/panelPermissions.js (همون الگو برای پنلِ ادمین) +const SUB_USER_PERMISSIONS = [ + { key: 'posts_stories', label: 'ثبت پست و استوری' }, + { key: 'reply_dm', label: 'جواب دادن به دایرکت' }, + { key: 'reply_comments', label: 'جواب دادن به کامنت' }, + { key: 'create_shop', label: 'ساخت فروشگاه' }, + { key: 'shop_products', label: 'ثبت و ویرایش کالا' }, + { key: 'order_status', label: 'تغییر وضعیت سفارشات' }, + { key: 'billboards', label: 'ساخت و ویرایش بیلبورد' }, + { key: 'projects', label: 'ساخت و مدیریت پروژه' }, + { key: 'academy_packages', label: 'ساخت و مدیریت پکیج آموزشی' }, + { key: 'bookings', label: 'ساخت رزرو آنلاین' } +] + +const SUB_USER_PERMISSION_KEYS = new Set(SUB_USER_PERMISSIONS.map((p) => p.key)) + +function sanitizeSubUserPermissions (input) { + if (!Array.isArray(input)) return [] + return [...new Set(input.filter((key) => SUB_USER_PERMISSION_KEYS.has(key)))] +} + +module.exports = { + SUB_USER_PERMISSIONS, + SUB_USER_PERMISSION_KEYS, + sanitizeSubUserPermissions +} diff --git a/utils/verifyBadge.js b/utils/verifyBadge.js index f60464e..81860ca 100644 --- a/utils/verifyBadge.js +++ b/utils/verifyBadge.js @@ -7,7 +7,10 @@ function isVerifiedBadge (badge) { } function computeVerifyBadge ({ is_verified, license_verified, gold_badge }) { - if (gold_badge) return 'gold' + // تیک طلایی فقط زمانی نمایش داده می‌شه که کاربر از قبل مدارک هویتش (تیک آبی) + // هم تایید شده باشه — وگرنه فعال بودنِ gold_badge (مثلاً به‌اشتباه یا قبل از + // تایید مدارک) نباید تیک آبیِ تازه‌تاییدشده رو بپوشونه + if (gold_badge && is_verified === 'verified') return 'gold' if (license_verified) return 'green' if (is_verified === 'verified') return 'blue' if (is_verified === 'pending') return 'gray'