Compare commits

...

2 Commits

Author SHA1 Message Date
payacom
e1abb40cc6 Reduce media bandwidth: compress uploads, add long-lived cache headers, gzip API
- Resize/re-encode every uploaded image server-side (posts, avatars, stories,
  shop products/logo, chat messages) via a shared sharp-based optimizer —
  caps dimensions to what's actually displayed and re-compresses in place,
  cutting a typical raw camera photo by ~90% with no visible quality loss
- Cap uploaded video resolution/bitrate (re-encode only when above 1280px/
  ~2.5Mbps) alongside the existing faststart step; apply faststart+cap to
  story and chat videos too (previously stored completely raw)
- Add long-lived Cache-Control to all /storage static routes so clients stop
  re-downloading unchanged media on every request
- Add gzip/brotli compression for JSON API responses (feed/explore/chat)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 17:55:09 +03:30
payacom
26979057d7 feat: mask Iranian phone numbers in user-generated text fields
کاربران نتونن با گذاشتن شماره موبایل/تلفن توی بیو، کپشن پست/ریلز، توضیحاتِ
فروشگاه/کالا/پروژه/دوره/پروفایلِ آموزشگاه، بیرون از پلتفرم تماس بدن. یک
util مشترک (utils/phoneNumberMasking.js) شماره‌های ایرانی رو حتی وقتی
رقم‌ها با فاصله/خط‌فاصله/نقطه از هم جدا شده باشن تشخیص می‌ده و فقط همون
بخش رو با * جایگزین می‌کنه — بدون اینکه به قیمت/سایز/کد سفارش و بقیه‌ی
اعدادِ عادی دست بزنه. موقعِ ذخیره (نه نمایش) اعمال می‌شه، پس هیچ endpointِ
خوندنی نیاز به تغییر نداره.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 15:54:26 +03:30
19 changed files with 418 additions and 41 deletions

View File

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

View File

@@ -7,6 +7,13 @@ const ChatRoomModel = require('../../../models/ChatRoomModel')
const ChatRoomMessageModel = require('../../../models/ChatRoomMessageModel')
const UserModel = require('../../../models/UserModel')
const { ProvinceModel, CityModel } = require('../../../models/StateCity')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
let optimizeVideoFaststart = async () => {}
try {
({ optimizeVideoFaststart } = require('../../../utils/postVideoProcessing'))
} catch (error) {
console.warn('[chatRoomController] postVideoProcessing unavailable:', error.message)
}
const {
slugifyTitle,
userCanSeeRoom,
@@ -227,6 +234,7 @@ const createRoom = async (req, res, next) => {
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(imageFile.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(imageFile.path, filePath)
await optimizeImageFile(filePath, { maxDimension: 800 })
imagePath = `/chat-rooms/${uniqueFileName}`
}
@@ -397,6 +405,13 @@ const sendRoomMessage = async (req, res, next) => {
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(uploaded.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(uploaded.path, filePath)
if (resolvedFileType === 'image' || (uploaded.type || '').startsWith('image/')) {
await optimizeImageFile(filePath)
} else if (resolvedFileType === 'video' || (uploaded.type || '').startsWith('video/')) {
optimizeVideoFaststart(filePath).catch((err) => {
console.error('[chatRoomController] video optimize background error:', err.message)
})
}
fileUrl = `/chat-rooms/messages/${uniqueFileName}`
}

View File

@@ -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')
@@ -29,6 +30,7 @@ try {
} catch (error) {
console.warn('[postController] postVideoProcessing unavailable:', error.message)
}
const { optimizeImageFile } = require('../../../utils/imageOptimize')
const createPostValidationRules = () => {
return [
@@ -85,9 +87,17 @@ 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
// کوچک/فشرده‌کردنِ عکس‌ها قبل از ذخیره — قبل از اینکه مسیرشون توی دیتابیس بره، چون
// مسیر (file.path) دست‌نخورده می‌ماند، فقط بایت‌های خودِ فایل جایگزین می‌شوند
await Promise.all(
files
.filter((file) => (file.mimetype || file.type || '').startsWith('image/'))
.map((file) => optimizeImageFile(file.path))
)
const filePaths = files.map((file) => ({
path: file.path,
type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image'
@@ -317,7 +327,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,

View File

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

View File

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

View File

@@ -6,6 +6,13 @@ const crypto = require('crypto')
const ShopModel = require('../../../models/ShopModel')
const ShopChatMessageModel = require('../../../models/ShopChatMessageModel')
const UserModel = require('../../../models/UserModel')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
let optimizeVideoFaststart = async () => {}
try {
({ optimizeVideoFaststart } = require('../../../utils/postVideoProcessing'))
} catch (error) {
console.warn('[shopChatController] postVideoProcessing unavailable:', error.message)
}
function chatError (res, status, message) {
return res.status(status).json({ error: true, message })
@@ -235,6 +242,13 @@ const sendShopChatFileMessage = async (req, res, next) => {
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(file.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(file.path, filePath, { overwrite: true })
if (fileType === 'image') {
await optimizeImageFile(filePath)
} else if (fileType === 'video') {
optimizeVideoFaststart(filePath).catch((err) => {
console.error('[shopChatController] video optimize background error:', err.message)
})
}
const fileUrl = `/shop-chat/${uniqueFileName}`
const message = await ShopChatMessageModel.create({

View File

@@ -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')
@@ -18,6 +19,7 @@ const { enrichMessage, emitChatEvents } = require('../../../helpers/chatHelpers'
const { viewerIsBlockedBy } = require('../../../utils/blockVisibility')
const ShopCategoryModel = require('../../../models/ShopCategoryModel')
const { buildUnifiedInterestScore } = require('../../../utils/exploreAlgorithm')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
/** Best-effort JWT decode for public browsing endpoints — never throws, never blocks guests. */
function getOptionalUserId (req) {
@@ -41,6 +43,9 @@ async function saveFile (file, folder, prefix) {
const uniqueFileName = `${prefix}-${Date.now()}${Math.random().toString().slice(2, 9)}${path.extname(file.name)}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(file.path, filePath, { overwrite: true })
if ((file.mimetype || file.type || '').startsWith('image/')) {
await optimizeImageFile(filePath)
}
return `/${folder}/${uniqueFileName}`
}
@@ -163,7 +168,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 +200,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

View File

@@ -7,6 +7,8 @@ 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 { optimizeImageFile } = require('../../../utils/imageOptimize')
const MS_DAY = 24 * 60 * 60 * 1000
const ANALYTICS_RANGE_DAYS = { '7d': 7, '30d': 30, '90d': 90, all: null }
@@ -44,7 +46,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 +193,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 })
@@ -285,6 +287,7 @@ const updateShopLogo = async (req, res, next) => {
const uniqueFileName = `${shop._id}-${Date.now()}${path.extname(logo.name)}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(logo.path, filePath, { overwrite: true })
await optimizeImageFile(filePath, { maxDimension: 800 })
shop.logo = `/shops/${uniqueFileName}`
await shop.save()

View File

@@ -13,6 +13,17 @@ const { filterStoryUserIdsByPrivateProfile } = require('../../../utils/privatePr
const { sendStoryReactionMessage } = require('../../../utils/storyChatMessage')
const { enrichMessage, emitChatEvents } = require('../../../helpers/chatHelpers')
const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
let optimizeVideoFaststart = async () => {}
try {
({ optimizeVideoFaststart } = require('../../../utils/postVideoProcessing'))
} catch (error) {
console.warn('[storyController] postVideoProcessing unavailable:', error.message)
}
// استوری روی صفحه‌ی کامل موبایل دیده می‌شه، ولی پهنا/ارتفاعِ خامِ دوربین (اغلب بیشتر از
// عرضِ صفحه‌ست) لازم نیست دست‌نخورده بمونه
const STORY_MAX_DIMENSION = 1440
const { STORY_PHOTO_FILTER_NAMES, applyStoryPhotoFilter } = require('../../../utils/storyPhotoFilters')
const LINK_OVERLAY_KINDS = ['pkg', 'project', 'billboard', 'shop', 'shopproduct', 'booking']
@@ -212,6 +223,13 @@ const createStoryBase64 = async (req, res) => {
const filename = `${Date.now()}-${userId}${ext}`
const filePath = path.join(storyStorageDir, filename)
await fs.promises.writeFile(filePath, buffer)
if (mediaType === 'image') {
await optimizeImageFile(filePath, { maxDimension: STORY_MAX_DIMENSION })
} else {
optimizeVideoFaststart(filePath).catch((err) => {
console.error('[storyController] video optimize background error:', err.message)
})
}
const story = await StoryModel.create({
user_id: userId,
@@ -243,6 +261,13 @@ const createStoryMultipart = async (req, res) => {
}
const mediaType = (file.mimetype || '').startsWith('video/') ? 'video' : 'image'
if (mediaType === 'image') {
await optimizeImageFile(file.path, { maxDimension: STORY_MAX_DIMENSION })
} else {
optimizeVideoFaststart(file.path).catch((err) => {
console.error('[storyController] video optimize background error:', err.message)
})
}
let overlays = []
try {

View File

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

View File

@@ -3,6 +3,11 @@ const fs = require('fs-extra')
const path = require('path')
const UserModel = require('../../../models/UserModel')
const jwt = require('jsonwebtoken')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
// آواتار هیچ‌وقت بزرگ‌تر از این نمایش داده نمی‌شود — عکسِ خامِ دوربین (چند مگابایت) رو
// برای یه دایره‌ی کوچیک نگه‌داشتن فقط مصرفِ اینترنت رو بالا می‌بره
const AVATAR_MAX_DIMENSION = 800
const setProfileImage = async (req, res, next) => {
try {
@@ -34,6 +39,7 @@ const setProfileImage = async (req, res, next) => {
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}`
await user.save()
@@ -81,6 +87,7 @@ const updateProfileImage = async (req, res, next) => {
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}`
await user.save()

View File

@@ -1,8 +1,16 @@
require('dotenv').config();
const express = require('express');
const app = express();
const compression = require('compression');
const path = require('path');
const crypto = require('crypto');
const { optimizeImageFile } = require('./utils/imageOptimize');
let optimizeVideoFaststart = async () => {};
try {
({ optimizeVideoFaststart } = require('./utils/postVideoProcessing'));
} catch (error) {
console.warn('[index] postVideoProcessing unavailable:', error.message);
}
const http = require('http').createServer(app);
const cors = require('cors');
const jwt = require('jsonwebtoken');
@@ -106,30 +114,41 @@ const io = require('socket.io')(http, {
app.set('io', io);
// gzip/brotli فقط برای پاسخ‌های متنی (JSON فید/اکسپلور/چت) — فیلترِ پیش‌فرضِ compression
// خودش مدیا (image/*، video/*) رو compressible نمی‌دونه و رد می‌کنه، پس نیازی به فیلترِ
// دستی نیست؛ فقط باید قبل از هر res.json/res.send دیگه‌ای بیاد
app.use(compression());
// افزایش limit برای Base64 (1000mb کافیه)
app.use(express.json({ limit: '1000mb' }));
app.use(express.urlencoded({ limit: '1000mb', extended: true }));
// کش طولانی برای مدیا — بدون این، مرورگر/اپ هر بار همون عکس/ویدیوی قبلی رو دوباره
// دانلود می‌کرد. immutable نمی‌ذاریم چون مسیر آپلود تازه (optimizeImageFile/
// optimizeVideoFaststart) گاهی چند ثانیه بعدِ همون ریسپانس، بایت‌های همون فایل رو در جا
// عوض می‌کنه؛ بدونِ immutable مرورگر با ETag/Last-Modified همچنان رواعتبارسنجی می‌کنه
const STATIC_MEDIA_OPTIONS = { maxAge: '365d' };
// مسیرهای مشخص قبل از /storage عمومی — پروفایل از هر دو محل (قدیم و جدید) سرو می‌شود
app.use('/storage/profiles', express.static(path.join(__dirname, '../storage/profiles')));
app.use('/storage/profiles', express.static(path.join(__dirname, 'storage/profiles')));
app.use('/storage/carts', express.static(path.join(__dirname, '../storage/carts')));
app.use('/storage/carts', express.static(path.join(__dirname, 'storage/carts')));
app.use('/storage/posts', express.static(path.join(__dirname, 'storage/posts')));
app.use('/storage/posts', express.static(path.join(__dirname, '../storage/posts')));
app.use('/storage/messages', express.static(path.join(__dirname, '../storage/messages')));
app.use('/storage/messages', express.static(path.join(__dirname, 'storage/messages')));
app.use('/storage/shop-chat', express.static(path.join(__dirname, '../storage/shop-chat')));
app.use('/storage/shop-chat', express.static(path.join(__dirname, 'storage/shop-chat')));
app.use('/storage/chat-rooms', express.static(path.join(__dirname, '../storage/chat-rooms')));
app.use('/storage/chat-rooms', express.static(path.join(__dirname, 'storage/chat-rooms')));
app.use('/storage/tickets', express.static(path.join(__dirname, '../storage/tickets')));
app.use('/storage/tickets', express.static(path.join(__dirname, 'storage/tickets')));
app.use('/storage/advertising', express.static(path.join(__dirname, '../storage/advertising')));
app.use('/storage/advertising', express.static(path.join(__dirname, 'storage/advertising')));
app.use('/storage/services', express.static(path.join(__dirname, '../storage/services')));
app.use('/storage/services', express.static(path.join(__dirname, 'storage/services')));
app.use('/storage', express.static(path.join(__dirname, 'storage')));
app.use('/storage/profiles', express.static(path.join(__dirname, '../storage/profiles'), STATIC_MEDIA_OPTIONS));
app.use('/storage/profiles', express.static(path.join(__dirname, 'storage/profiles'), STATIC_MEDIA_OPTIONS));
app.use('/storage/carts', express.static(path.join(__dirname, '../storage/carts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/carts', express.static(path.join(__dirname, 'storage/carts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/posts', express.static(path.join(__dirname, 'storage/posts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/posts', express.static(path.join(__dirname, '../storage/posts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/messages', express.static(path.join(__dirname, '../storage/messages'), STATIC_MEDIA_OPTIONS));
app.use('/storage/messages', express.static(path.join(__dirname, 'storage/messages'), STATIC_MEDIA_OPTIONS));
app.use('/storage/shop-chat', express.static(path.join(__dirname, '../storage/shop-chat'), STATIC_MEDIA_OPTIONS));
app.use('/storage/shop-chat', express.static(path.join(__dirname, 'storage/shop-chat'), STATIC_MEDIA_OPTIONS));
app.use('/storage/chat-rooms', express.static(path.join(__dirname, '../storage/chat-rooms'), STATIC_MEDIA_OPTIONS));
app.use('/storage/chat-rooms', express.static(path.join(__dirname, 'storage/chat-rooms'), STATIC_MEDIA_OPTIONS));
app.use('/storage/tickets', express.static(path.join(__dirname, '../storage/tickets'), STATIC_MEDIA_OPTIONS));
app.use('/storage/tickets', express.static(path.join(__dirname, 'storage/tickets'), STATIC_MEDIA_OPTIONS));
app.use('/storage/advertising', express.static(path.join(__dirname, '../storage/advertising'), STATIC_MEDIA_OPTIONS));
app.use('/storage/advertising', express.static(path.join(__dirname, 'storage/advertising'), STATIC_MEDIA_OPTIONS));
app.use('/storage/services', express.static(path.join(__dirname, '../storage/services'), STATIC_MEDIA_OPTIONS));
app.use('/storage/services', express.static(path.join(__dirname, 'storage/services'), STATIC_MEDIA_OPTIONS));
app.use('/storage', express.static(path.join(__dirname, 'storage'), STATIC_MEDIA_OPTIONS));
require('./boot');
@@ -394,6 +413,13 @@ app.post('/api/v1/chat/file', [blockCheck, chatPeerBlock], async (req, res) => {
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(file.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(file.path, filePath)
if (resolvedFileType === 'image' || (file.type || '').startsWith('image/')) {
await optimizeImageFile(filePath)
} else if (resolvedFileType === 'video' || (file.type || '').startsWith('video/')) {
optimizeVideoFaststart(filePath).catch((err) => {
console.error('[chat/file] video optimize background error:', err.message)
})
}
fileUrl = `/messages/${uniqueFileName}`
}

69
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"axios": "^1.6.7",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
"compression": "^1.8.2",
"connect-redis": "^10.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
@@ -2569,6 +2570,74 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://mirror-npm.runflare.com/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.2",
"resolved": "https://mirror-npm.runflare.com/compression/-/compression-1.8.2.tgz",
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"destroy": "1.2.0",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://mirror-npm.runflare.com/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://mirror-npm.runflare.com/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://mirror-npm.runflare.com/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression/node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://mirror-npm.runflare.com/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",

View File

@@ -16,6 +16,7 @@
"axios": "^1.6.7",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
"compression": "^1.8.2",
"connect-redis": "^10.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",

View File

@@ -1,6 +1,7 @@
const fs = require('fs-extra')
const path = require('path')
const axios = require('axios')
const { optimizeImageFile } = require('./imageOptimize')
const EXT_BY_CONTENT_TYPE = {
'image/png': '.png',
@@ -32,6 +33,7 @@ async function downloadImageToProfiles(url, baseName) {
const filePath = path.join(uploadDir, uniqueFileName)
await fs.writeFile(filePath, Buffer.from(response.data))
await optimizeImageFile(filePath, { maxDimension: 800 })
return `/profiles/${uniqueFileName}`
}

59
utils/imageOptimize.js Normal file
View File

@@ -0,0 +1,59 @@
const sharp = require('sharp')
const fs = require('fs-extra')
const path = require('path')
// اندازه/کیفیتِ نمایش — نه سایزِ چاپ؛ کاربر روی موبایل/وب حداکثر تا این عرض می‌بینه،
// پس آپلودِ خامِ دوربین (معمولاً ۳۰۰۰-۴۰۰۰ پیکسل) فقط مصرفِ اینترنت رو چند برابر می‌کنه
// بدون هیچ بهبودِ بصری. این مقادیر برای محتوای پست/استوری تنظیم شده‌اند.
const DEFAULT_MAX_DIMENSION = 1600
const DEFAULT_JPEG_QUALITY = 82
/**
* تصویرِ رویِ دیسک را (در صورت لازم) کوچک و فشرده می‌کند — همون فایل با همون
* پسوند/فرمت بازنویسی می‌شود تا Content-Type و مسیرهای ذخیره‌شده در دیتابیس تغییر نکنند.
* اگر فایلِ فشرده‌شده کوچک‌تر نبود (مثلاً عکسِ کوچک/از قبل فشرده)، فایلِ اصلی نگه داشته می‌شود.
*/
async function optimizeImageFile(filePath, { maxDimension = DEFAULT_MAX_DIMENSION, jpegQuality = DEFAULT_JPEG_QUALITY } = {}) {
if (!filePath) return
let tempPath = null
try {
if (!(await fs.pathExists(filePath))) return
const ext = path.extname(filePath).toLowerCase()
const metadata = await sharp(filePath, { failOn: 'none' }).metadata()
if (!metadata.width || !metadata.height) return // فرمتِ ناشناس برای sharp (مثلاً gif متحرک) — دست‌نخورده می‌ماند
const needsResize = metadata.width > maxDimension || metadata.height > maxDimension
let pipeline = sharp(filePath, { failOn: 'none' }).rotate() // rotate() بدون آرگومان: EXIF orientation را اعمال و حذف می‌کند
if (needsResize) {
pipeline = pipeline.resize({ width: maxDimension, height: maxDimension, fit: 'inside', withoutEnlargement: true })
}
if (ext === '.png') {
pipeline = pipeline.png({ compressionLevel: 9, palette: true })
} else if (ext === '.webp') {
pipeline = pipeline.webp({ quality: jpegQuality })
} else {
// .jpg/.jpeg و هر پسوندِ دیگرِ عکس (heic و مشابه، اگر sharp بتواند دیکد کند)
pipeline = pipeline.jpeg({ quality: jpegQuality, mozjpeg: true })
}
tempPath = path.join(path.dirname(filePath), `optimize-${Date.now()}-${path.basename(filePath)}`)
await pipeline.toFile(tempPath)
const [originalStat, optimizedStat] = await Promise.all([fs.stat(filePath), fs.stat(tempPath)])
if (optimizedStat.size >= originalStat.size) {
await fs.remove(tempPath)
return
}
await fs.rename(tempPath, filePath)
console.log(`[imageOptimize] ${path.basename(filePath)}: ${originalStat.size} -> ${optimizedStat.size} bytes`)
} catch (error) {
console.error('[imageOptimize] failed:', error.message)
if (tempPath) await fs.remove(tempPath).catch(() => {})
}
}
module.exports = { optimizeImageFile, DEFAULT_MAX_DIMENSION, DEFAULT_JPEG_QUALITY }

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 }

View File

@@ -16,26 +16,73 @@ async function runFfmpeg(command) {
}
}
/** faststart بدون re-encode — شروع پخش سریع‌تر */
// موبایل/وب هیچ‌جا بیشتر از این نمایش داده نمی‌شه — ویدیوی خامِ دوربین (اغلب ۱۰۸۰p+ با
// بیت‌ریتِ چند برابرِ لازم) رو بدون افتِ کیفیتِ محسوس به این سقف می‌رسونیم
const VIDEO_MAX_DIMENSION = 1280;
const VIDEO_MAX_BITRATE_KBPS = 2500;
async function probeVideo(filePath) {
try {
const { stdout } = await execPromise(
`ffprobe -v error -select_streams v:0 -show_entries stream=width,height,bit_rate -show_entries format=bit_rate -of json "${filePath}"`
);
const data = JSON.parse(stdout);
const stream = data.streams?.[0] || {};
const width = Number(stream.width) || 0;
const height = Number(stream.height) || 0;
const bitRateBps = Number(stream.bit_rate) || Number(data.format?.bit_rate) || 0;
return { width, height, bitRateKbps: bitRateBps ? Math.round(bitRateBps / 1000) : 0 };
} catch (error) {
console.error("[postVideoProcessing] probe failed:", error.message);
return null;
}
}
/**
* فایل رو یا فقط faststart می‌کنه (بدون کیفیت‌کاهی، وقتی از قبل کوچیک/بهینه‌ست) یا
* وقتی رزولوشن/بیت‌ریت بیشتر از چیزیه که کاربر واقعاً می‌بینه، دوباره با سقفِ
* رزولوشن+بیت‌ریت انکود می‌کنه (که faststart هم توش هست، جداگانه لازم نیست)
*/
async function optimizeVideoFaststart(filePath) {
if (!(await fs.pathExists(filePath))) return;
const dir = path.dirname(filePath);
const base = path.basename(filePath);
const tempPath = path.join(dir, `faststart-${Date.now()}-${base}`);
const info = await probeVideo(filePath);
const ok = await runFfmpeg(
`ffmpeg -i "${filePath}" -c copy -movflags +faststart "${tempPath}" -y`
);
const needsReencode =
info && ((info.width > VIDEO_MAX_DIMENSION || info.height > VIDEO_MAX_DIMENSION) ||
(info.bitRateKbps > 0 && info.bitRateKbps > VIDEO_MAX_BITRATE_KBPS * 1.3));
const tempPath = path.join(dir, `optimize-${Date.now()}-${base}`);
const ok = needsReencode
? await runFfmpeg(
`ffmpeg -i "${filePath}" -vf "scale='min(${VIDEO_MAX_DIMENSION},iw)':'min(${VIDEO_MAX_DIMENSION},ih)':force_original_aspect_ratio=decrease" -c:v libx264 -preset veryfast -crf 25 -maxrate ${VIDEO_MAX_BITRATE_KBPS}k -bufsize ${VIDEO_MAX_BITRATE_KBPS * 2}k -c:a aac -b:a 128k -movflags +faststart "${tempPath}" -y`
)
: await runFfmpeg(
`ffmpeg -i "${filePath}" -c copy -movflags +faststart "${tempPath}" -y`
);
if (!ok || !(await fs.pathExists(tempPath))) return;
try {
const [originalStat, optimizedStat] = await Promise.all([
fs.stat(filePath),
fs.stat(tempPath),
]);
// اگه انکودِ جدید بزرگ‌تر شد (ویدیوی خیلی کوتاه/از قبل فشرده)، فایلِ اصلی نگه داشته می‌شه
if (needsReencode && optimizedStat.size >= originalStat.size) {
await fs.remove(tempPath).catch(() => {});
return;
}
await fs.rename(tempPath, filePath);
console.log(`[postVideoProcessing] faststart applied: ${base}`);
console.log(
`[postVideoProcessing] ${needsReencode ? "re-encoded" : "faststart applied"}: ${base} (${originalStat.size} -> ${optimizedStat.size} bytes)`
);
} catch (error) {
await fs.remove(tempPath).catch(() => {});
console.error("[postVideoProcessing] faststart rename failed:", error.message);
console.error("[postVideoProcessing] rename failed:", error.message);
}
}

View File

@@ -15,10 +15,19 @@ const STORY_PHOTO_FILTERS = {
const STORY_PHOTO_FILTER_NAMES = Object.keys(STORY_PHOTO_FILTERS)
// همون سقفِ اندازه‌ی storyController.js — این بافر بعداً دوباره از موبایل به createStoryBase64
// آپلود می‌شه، پس کوچیک نگه‌داشتنش اینجا هم رفت‌وبرگشتِ پیش‌نمایش و هم آپلودِ نهایی رو کم می‌کنه
const STORY_MAX_DIMENSION = 1440
/** buffer ورودی -> بافرِ jpeg خروجی با فیلترِ اعمال‌شده. filterName نامعتبر => همون تصویر اصلی. */
async function applyStoryPhotoFilter (buffer, filterName) {
const apply = STORY_PHOTO_FILTERS[filterName] || STORY_PHOTO_FILTERS.normal
return apply(sharp(buffer)).jpeg({ quality: 90 }).toBuffer()
const image = apply(sharp(buffer).rotate())
const metadata = await sharp(buffer).metadata()
const resized = (metadata.width > STORY_MAX_DIMENSION || metadata.height > STORY_MAX_DIMENSION)
? image.resize({ width: STORY_MAX_DIMENSION, height: STORY_MAX_DIMENSION, fit: 'inside', withoutEnlargement: true })
: image
return resized.jpeg({ quality: 85, mozjpeg: true }).toBuffer()
}
module.exports = { STORY_PHOTO_FILTERS, STORY_PHOTO_FILTER_NAMES, applyStoryPhotoFilter }