feat: mask Iranian phone numbers in user-generated text fields
کاربران نتونن با گذاشتن شماره موبایل/تلفن توی بیو، کپشن پست/ریلز، توضیحاتِ فروشگاه/کالا/پروژه/دوره/پروفایلِ آموزشگاه، بیرون از پلتفرم تماس بدن. یک util مشترک (utils/phoneNumberMasking.js) شمارههای ایرانی رو حتی وقتی رقمها با فاصله/خطفاصله/نقطه از هم جدا شده باشن تشخیص میده و فقط همون بخش رو با * جایگزین میکنه — بدون اینکه به قیمت/سایز/کد سفارش و بقیهی اعدادِ عادی دست بزنه. موقعِ ذخیره (نه نمایش) اعمال میشه، پس هیچ endpointِ خوندنی نیاز به تغییر نداره. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
81
utils/phoneNumberMasking.js
Normal file
81
utils/phoneNumberMasking.js
Normal file
@@ -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 }
|
||||
Reference in New Issue
Block a user