Initial commit
This commit is contained in:
@@ -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
129
helpers/expiredMessages.js
Normal 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
104
helpers/viewOnceMessages.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user