This commit is contained in:
payacom
2026-10-01 21:12:05 +03:30
parent 806471241e
commit d4ebc20aa8
41 changed files with 1015 additions and 135 deletions

View File

@@ -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({

View File

@@ -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

View File

@@ -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
}

View File

@@ -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')

View File

@@ -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)
}

View File

@@ -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)
// کاملاً از کیف پول پوشش داده شد — اصلاً نیازی به درگاه نیست

View File

@@ -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')

View File

@@ -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

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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()