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:
payacom
2026-09-15 15:54:26 +03:30
parent 5f99516078
commit 26979057d7
8 changed files with 102 additions and 14 deletions

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