Compare commits
2 Commits
5f99516078
...
e1abb40cc6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1abb40cc6 | ||
|
|
26979057d7 |
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
64
index.js
64
index.js
@@ -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
69
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
59
utils/imageOptimize.js
Normal 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 }
|
||||
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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user