Initial commit

This commit is contained in:
payacom
2026-07-08 21:01:30 +03:30
parent 59c7c8e11c
commit 0b01b262fa
38 changed files with 1415 additions and 288 deletions

View File

@@ -30,6 +30,14 @@ async function enrichMessage(message) {
return {
...doc,
createdAt: formatTimeOnly(doc.createdAt),
createdAtIso: doc.createdAt ? new Date(doc.createdAt).toISOString() : undefined,
expiresAt: doc.expiresAt ? new Date(doc.expiresAt).toISOString() : undefined,
viewOnce: Boolean(doc.viewOnce),
viewOnceLocked: Boolean(doc.viewOnceLocked),
viewOnceExpired: Boolean(doc.viewOnceExpired),
viewOnceOpenedAt: doc.viewOnceOpenedAt
? new Date(doc.viewOnceOpenedAt).toISOString()
: undefined,
replyTo,
forwardedFrom: doc.forwardedFrom || undefined
}

129
helpers/expiredMessages.js Normal file
View File

@@ -0,0 +1,129 @@
const path = require('path')
const fs = require('fs-extra')
const MessageModel = require('../models/MessageModel')
const ALLOWED_SELF_DESTRUCT_SECONDS = [10, 30, 60, 300, 3600]
function parseSelfDestructSeconds(raw) {
if (raw == null || raw === '' || raw === '0' || raw === 0) return null
const n = parseInt(String(raw), 10)
if (!Number.isFinite(n) || !ALLOWED_SELF_DESTRUCT_SECONDS.includes(n)) return null
return n
}
function buildExpiresAt(seconds) {
if (!seconds) return undefined
return new Date(Date.now() + seconds * 1000)
}
function activeMessageFilter(baseFilter) {
return {
$and: [
baseFilter,
{
$or: [
{ expiresAt: { $exists: false } },
{ expiresAt: null },
{ expiresAt: { $gt: new Date() } }
]
}
]
}
}
async function removeMessageFiles(messages) {
for (const msg of messages) {
if (!msg.file) continue
const relative = String(msg.file).replace(/^\//, '')
const filePath = path.join(__dirname, '../storage', relative)
if (fs.existsSync(filePath)) {
await fs.remove(filePath).catch(() => {})
}
}
}
async function deleteMessagesAndNotify(io, messages, options = {}) {
if (!messages?.length) return []
await removeMessageFiles(messages)
const deletedIds = messages.map((m) => String(m._id))
await MessageModel.deleteMany({ _id: { $in: deletedIds } })
if (io) {
const byThread = new Map()
messages.forEach((m) => {
const s = String(m.senderId)
const r = String(m.receiverId)
const key = [s, r].sort().join(':')
if (!byThread.has(key)) byThread.set(key, { s, r, ids: [] })
byThread.get(key).ids.push(String(m._id))
})
byThread.forEach(({ s, r, ids }) => {
io.to(`chat:${s}:${r}`)
.to(`chat:${r}:${s}`)
.emit('messagesDeleted', {
messageIds: ids,
deletedBy: options.deletedBy,
reason: options.reason || (options.deletedBy ? 'manual' : 'expired')
})
io.to(`user:${s}`).to(`user:${r}`).emit('chatListUpdate', { senderId: s, receiverId: r })
})
}
return deletedIds
}
async function purgeExpiredMessages(io) {
try {
const expired = await MessageModel.find({ expiresAt: { $lte: new Date() } })
if (!expired.length) return []
return deleteMessagesAndNotify(io, expired)
} catch (err) {
console.error('purgeExpiredMessages error:', err)
return []
}
}
const scheduledTimers = new Map()
function scheduleMessageExpiry(io, message) {
if (!message?.expiresAt || !message._id) return
const id = String(message._id)
if (scheduledTimers.has(id)) {
clearTimeout(scheduledTimers.get(id))
}
const ms = new Date(message.expiresAt).getTime() - Date.now()
if (ms <= 0) {
MessageModel.findById(id)
.then((doc) => doc && deleteMessagesAndNotify(io, [doc]))
.catch(() => {})
return
}
const timer = setTimeout(async () => {
scheduledTimers.delete(id)
try {
const doc = await MessageModel.findById(id)
if (doc && doc.expiresAt && new Date(doc.expiresAt) <= new Date()) {
await deleteMessagesAndNotify(io, [doc])
}
} catch (err) {
console.error('scheduleMessageExpiry error:', err)
}
}, ms)
scheduledTimers.set(id, timer)
}
module.exports = {
ALLOWED_SELF_DESTRUCT_SECONDS,
parseSelfDestructSeconds,
buildExpiresAt,
activeMessageFilter,
purgeExpiredMessages,
scheduleMessageExpiry,
deleteMessagesAndNotify
}

104
helpers/viewOnceMessages.js Normal file
View File

@@ -0,0 +1,104 @@
const MessageModel = require('../models/MessageModel')
const { deleteMessagesAndNotify } = require('./expiredMessages')
function parseViewOnce(raw) {
if (raw == null || raw === '' || raw === false || raw === 'false' || raw === 0) {
return false
}
return raw === true || raw === 'true' || raw === 1 || raw === '1'
}
const VIEW_ONCE_MEDIA = new Set(['image', 'video', 'voice'])
function isViewOnceMediaType(fileType) {
return VIEW_ONCE_MEDIA.has(fileType)
}
/** Hide file URL for receiver until they open view-once media */
function sanitizeMessageForViewer(message, viewerId) {
const doc = message._doc ? { ...message._doc } : { ...message }
const viewer = String(viewerId)
const sender = String(doc.senderId)
const receiver = String(doc.receiverId)
if (!doc.viewOnce || !isViewOnceMediaType(doc.fileType)) {
return doc
}
if (viewer === sender) {
return doc
}
if (viewer === receiver) {
if (doc.viewOnceOpenedAt) {
return {
...doc,
file: '',
viewOnceExpired: true
}
}
return {
...doc,
file: '',
viewOnceLocked: true
}
}
return doc
}
async function openViewOnceMessage(messageId, userId) {
const msg = await MessageModel.findById(messageId)
if (!msg || !msg.viewOnce) {
return { error: 'پیام یافت نشد', status: 404 }
}
if (String(msg.receiverId) !== String(userId)) {
return { error: 'دسترسی مجاز نیست', status: 403 }
}
if (msg.viewOnceOpenedAt) {
return { error: 'این پیام قبلاً باز شده است', status: 410 }
}
msg.viewOnceOpenedAt = new Date()
await msg.save()
return {
message: msg.toObject(),
file: msg.file,
fileType: msg.fileType
}
}
async function completeViewOnceMessage(io, messageId, userId) {
const msg = await MessageModel.findById(messageId)
if (!msg || !msg.viewOnce) {
return { error: 'پیام یافت نشد', status: 404 }
}
const uid = String(userId)
const isReceiver = String(msg.receiverId) === uid
const isSender = String(msg.senderId) === uid
if (!isReceiver && !isSender) {
return { error: 'دسترسی مجاز نیست', status: 403 }
}
if (isReceiver && !msg.viewOnceOpenedAt) {
return { error: 'پیام هنوز باز نشده', status: 400 }
}
await deleteMessagesAndNotify(io, [msg], {
deletedBy: userId,
reason: 'viewOnce'
})
return { deletedIds: [String(msg._id)] }
}
module.exports = {
parseViewOnce,
isViewOnceMediaType,
sanitizeMessageForViewer,
openViewOnceMessage,
completeViewOnceMessage
}