diff --git a/controllers/application/academy/academyControllers.js b/controllers/application/academy/academyControllers.js index 1317150..849ee13 100644 --- a/controllers/application/academy/academyControllers.js +++ b/controllers/application/academy/academyControllers.js @@ -19,6 +19,7 @@ const { buildUnifiedInterestScore, blendFreshIntoRanked: blendInterestIntoRanked const { resolveCommentModerationStatus } = require('../../../utils/contentModeration') const { saveCommentWithModerationStatus } = require('../../../utils/commentStatusSave') const { isVerifiedBadge } = require('../../../utils/verifyBadge') +const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') const { coursePublicStatusFilter, academyContentPublicStatusFilter, @@ -232,7 +233,7 @@ const academyProfile = async (req, res, next) => { academy.academy_image = `/profiles/${uniqueFileName}` academy.academy_name = name academy.sheba = `IR${shebaDigits}` - academy.bio = bio + academy.bio = maskPhoneNumbers(bio) academy.tag = tag await academy.save() @@ -363,7 +364,7 @@ const creatCourse = async (req, res, next) => { offer: offer || '0', teacher_name, academyId: academy._id, - caption, + caption: maskPhoneNumbers(caption), course_time, number_of_course_content, is_free: isFreePackage, @@ -460,7 +461,7 @@ const updateCourse = async (req, res, next) => { if (cuorse_name !== undefined) course.cuorse_name = cuorse_name if (category !== undefined) course.category = category if (offer !== undefined) course.offer = offer || '0' - if (caption !== undefined) course.caption = caption + if (caption !== undefined) course.caption = maskPhoneNumbers(caption) if (course_time !== undefined) course.course_time = course_time if (number_of_course_content !== undefined) { course.number_of_course_content = number_of_course_content } if (teacher_name !== undefined) course.teacher_name = teacher_name @@ -5039,4 +5040,4 @@ module.exports = { // دریافت دسته‌بندی‌های ویژه (برای نمایش در صفحه اصلی) getFeatured -} +} diff --git a/controllers/application/posts/postController.js b/controllers/application/posts/postController.js index 5c795b1..6fa3e95 100644 --- a/controllers/application/posts/postController.js +++ b/controllers/application/posts/postController.js @@ -18,6 +18,7 @@ const { isHiddenFromPublic } = require('../../../utils/accountLifecycle') const { viewerCanSeeUserContent } = require('../../../utils/privateProfile') const { isPostPubliclyVisible } = require('../../../utils/postModeration') const { postStatusFilter, PUBLIC_POST_STATUSES, commentStatusFilter } = require('../../../utils/moderationStatus') +const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') const { resolvePostModerationStatus } = require('../../../utils/contentModeration') const { savePostWithModerationStatus } = require('../../../utils/postStatusSave') const { createTagNotifications } = require('../../../utils/tagNotification') @@ -85,7 +86,7 @@ const createPost = async (req, res) => { const captionText = caption != null && String(caption).trim() - ? String(caption).trim().slice(0, 2000) + ? maskPhoneNumbers(String(caption).trim().slice(0, 2000)) : null const filePaths = files.map((file) => ({ @@ -317,7 +318,7 @@ const updatePost = async (req, res) => { if (caption != null) { const captionText = String(caption).trim() - ? String(caption).trim().slice(0, 2000) + ? maskPhoneNumbers(String(caption).trim().slice(0, 2000)) : null const moderation = resolvePostModerationStatus({ caption: captionText, diff --git a/controllers/application/projects/createProjectController.js b/controllers/application/projects/createProjectController.js index eac0d40..8a41514 100644 --- a/controllers/application/projects/createProjectController.js +++ b/controllers/application/projects/createProjectController.js @@ -9,6 +9,7 @@ const { respondProjectProfileIncomplete } = require('../../../utils/projectProfile') const { isVerifiedBadge } = require('../../../utils/verifyBadge') +const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') async function activateFreeProjectIfEligible (user, project) { if (project.project_type !== 'free') return { activated: false } @@ -207,7 +208,7 @@ const createProject = async (req, res, next) => { address: address || null, offer_time, offer_price, - description, + description: maskPhoneNumbers(description), ...(payment_method ? { payment_method } : {}), start_date: start_date || null, work_hours_start: work_hours_start || null, diff --git a/controllers/application/projects/updateProjectController.js b/controllers/application/projects/updateProjectController.js index 28c52a7..9575c7a 100644 --- a/controllers/application/projects/updateProjectController.js +++ b/controllers/application/projects/updateProjectController.js @@ -15,6 +15,7 @@ const { canCreateProject, respondProjectProfileIncomplete } = require('../../../utils/projectProfile') +const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') const saveProjectComment = async ({ targetUserId, projectId, @@ -386,7 +387,7 @@ const editProject = async (req, res, next) => { project.address = address || null project.offer_time = offer_time project.offer_price = offer_price - project.description = description + project.description = maskPhoneNumbers(description) if (payment_method) project.payment_method = payment_method project.start_date = start_date || null project.work_hours_start = work_hours_start || null diff --git a/controllers/application/shop-products/shopProductController.js b/controllers/application/shop-products/shopProductController.js index 70f7023..e012796 100644 --- a/controllers/application/shop-products/shopProductController.js +++ b/controllers/application/shop-products/shopProductController.js @@ -10,6 +10,7 @@ const PostModel = require('../../../models/PostModel') const OrderModel = require('../../../models/OrderModel') const CommentModel = require('../../../models/CommentModel') const UserModel = require('../../../models/UserModel') +const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') const MessageModel = require('../../../models/MessageModel') const { commentStatusFilter } = require('../../../utils/moderationStatus') const { saveCommentWithModerationStatus } = require('../../../utils/commentStatusSave') @@ -163,7 +164,7 @@ const createShopProduct = async (req, res, next) => { shop: shop._id, catalogProduct: catalogProduct._id, title: name || catalogProduct.name, - description: description || catalogProduct.description || null, + description: maskPhoneNumbers(description || catalogProduct.description) || null, images: clonedFields.images || images, primaryImageIndex: clonedFields.primaryImageIndex ?? (Number(primaryImageIndex) || 0), variants: clonedFields.variants || variants, @@ -195,7 +196,7 @@ const updateShopProduct = async (req, res, next) => { const { title, description, category, sub_category: subCategory, sub_sub_category: subSubCategory, status } = req.body if (title) listing.title = title - if (description !== undefined) listing.description = description + if (description !== undefined) listing.description = maskPhoneNumbers(description) if (category) listing.category = category if (subCategory) listing.sub_category = subCategory if (subSubCategory) listing.sub_sub_category = subSubCategory diff --git a/controllers/application/shops/shopController.js b/controllers/application/shops/shopController.js index b70bc3a..d6bfe1e 100644 --- a/controllers/application/shops/shopController.js +++ b/controllers/application/shops/shopController.js @@ -7,6 +7,7 @@ const ShopProductListingModel = require('../../../models/ShopProductListingModel const ShopRatingModel = require('../../../models/ShopRatingModel') const ShopFollowModel = require('../../../models/ShopFollowModel') const OrderModel = require('../../../models/OrderModel') +const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') const MS_DAY = 24 * 60 * 60 * 1000 const ANALYTICS_RANGE_DAYS = { '7d': 7, '30d': 30, '90d': 90, all: null } @@ -44,7 +45,7 @@ const createDraftShop = async (req, res, next) => { const shop = await ShopModel.create({ owner: req.user._id, name, - description: description || null, + description: description ? maskPhoneNumbers(description) : null, status: 'draft' }) @@ -191,7 +192,7 @@ const updateShopBasic = async (req, res, next) => { const { name, description } = req.body if (name) shop.name = name - if (description !== undefined) shop.description = description || null + if (description !== undefined) shop.description = description ? maskPhoneNumbers(description) : null await shop.save() return res.json({ message: 'اطلاعات فروشگاه ذخیره شد', shop }) diff --git a/controllers/application/verify/conversationController.js b/controllers/application/verify/conversationController.js index 95bc845..9d10312 100644 --- a/controllers/application/verify/conversationController.js +++ b/controllers/application/verify/conversationController.js @@ -1,6 +1,7 @@ /* eslint-disable camelcase */ const UserModel = require('../../../models/UserModel') const jwt = require('jsonwebtoken') +const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') const setConversation = async (req, res, next) => { try { @@ -23,7 +24,7 @@ const setConversation = async (req, res, next) => { }) } - user.bio = bio + user.bio = maskPhoneNumbers(bio) user.conversation_projects = conversation_projects await user.save() @@ -58,7 +59,7 @@ const updateConversation = async (req, res, next) => { message: 'کاربری با این شناسه یافت نشد' }) } - user.bio = bio + user.bio = maskPhoneNumbers(bio) user.conversation_projects = conversation_projects await user.save() diff --git a/utils/phoneNumberMasking.js b/utils/phoneNumberMasking.js new file mode 100644 index 0000000..4080ade --- /dev/null +++ b/utils/phoneNumberMasking.js @@ -0,0 +1,81 @@ +/** + * Masks Iranian mobile/landline phone numbers inside free-text fields (bio, captions, + * descriptions, ...) so users can't share off-platform contact numbers through them. + * Applied at write time (before saving), not at read time — so the raw number never + * even reaches storage. + * + * Must survive users spacing digits out to dodge a naive "11 digits in a row" regex + * (e.g. "0912 345 6789", "0912-345-6789", "09123456789"), while NOT flagging ordinary + * numbers (prices, sizes, order codes, percentages) that aren't shaped like a phone + * number. The trick: find any run of digits *and common separators* long enough to + * matter, strip the separators back out, and only mask it if what's left is actually + * shaped like an Iranian phone number (mobile 09xxxxxxxxx, or landline 0[1-8]xxxxxxxx[x], + * optionally with a +98/0098/98 country-code prefix). + */ + +const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹' +const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩' + +function toWesternDigits (str) { + return str.replace(/[۰-۹٠-٩]/g, (ch) => { + const p = PERSIAN_DIGITS.indexOf(ch) + if (p !== -1) return String(p) + const a = ARABIC_DIGITS.indexOf(ch) + return a !== -1 ? String(a) : ch + }) +} + +/** Normalizes a leading country-code prefix (+98 / 0098 / 98) down to a plain leading 0. */ +function normalizeLeadingCountryCode (digits) { + if (digits.startsWith('0098')) return `0${digits.slice(4)}` + if (digits.startsWith('98') && digits.length >= 12) return `0${digits.slice(2)}` + return digits +} + +/** + * Iranian mobile: 09 + 9 digits = 11 digits. + * Iranian landline: 0 + area-code digit 1-8 (never 9, that's mobile) + 8-9 more digits, + * 10-11 digits total (Tehran's 021 + 8-digit local number is the 11-digit case; several + * other cities use a 3-digit area code + 7-digit local number, the 10-digit case). + */ +function isIranianPhoneNumber (rawDigits) { + const digits = normalizeLeadingCountryCode(rawDigits) + if (digits.length < 10 || digits.length > 11) return false + if (/^09\d{9}$/.test(digits)) return true + // کدِ شهرِ ۲ یا ۳ رقمی + شماره‌ی محلیِ ۷ یا ۸ رقمی = جمعاً ۱۰ یا ۱۱ رقم (با صفرِ ابتدایی) + if (/^0[1-8]\d{8,9}$/.test(digits)) return true + return false +} + +// یک رقم، بعدش حداقل ۸ کاراکترِ رقم/جداکننده (فاصله، خط‌فاصله، نقطه، پرانتز، آندرلاین)، +// بعدش یک رقم — یعنی حداقل ۱۰ کاراکتر جمعاً، هم‌اندازه‌ی کوتاه‌ترین شماره‌ی معتبر +const CANDIDATE_RUN = /[0-9۰-۹٠-٩][0-9۰-۹٠-٩\s\-.()_+]{8,}[0-9۰-۹٠-٩]/g + +/** + * Replaces any Iranian phone/mobile number found in `text` — including ones with + * digits spaced/dashed/dotted apart — with asterisks. Leaves everything else + * (prices, sizes, order numbers, short numbers) untouched. + */ +function maskPhoneNumbers (text) { + if (!text || typeof text !== 'string') return text + + return text.replace(CANDIDATE_RUN, (match) => { + const digitsOnly = toWesternDigits(match).replace(/[^0-9]/g, '') + if (!isIranianPhoneNumber(digitsOnly)) return match + return '*'.repeat(match.length) + }) +} + +/** true فقط اگه واقعاً یه شماره‌ی ایرانی توی متن پیدا بشه — برای جاهایی که فقط لازمه‌ی چک‌کردن، نه ماسک‌کردن */ +function containsPhoneNumber (text) { + if (!text || typeof text !== 'string') return false + let found = false + text.replace(CANDIDATE_RUN, (match) => { + const digitsOnly = toWesternDigits(match).replace(/[^0-9]/g, '') + if (isIranianPhoneNumber(digitsOnly)) found = true + return match + }) + return found +} + +module.exports = { maskPhoneNumbers, containsPhoneNumber }