增加消息自动删除

This commit is contained in:
619dev
2026-03-28 21:26:26 +08:00
parent bd0b796912
commit d80c3e522f
11 changed files with 317 additions and 2 deletions

View File

@@ -17,6 +17,7 @@
| 🗝️ 零知识服务器 | 服务器只存储密文,私钥仅在设备本地(四层持久化) |
| 📹 视频/语音通话 | WebRTC P2P1:1+ Mesh多人Cloudflare TURN 穿透 |
| 👥 群聊 | 最多 2000 人群组,纯文本消息(无加密),免打扰模式,成员管理 |
| ⏱️ 消息自动删除 | 5 档可选(永不/1天/3天/1周/1月私聊双方均可设置群聊群主专属 |
| 🔔 消息推送 | Web Push (VAPID) + OneSignal 双通道,离线也能收到通知 |
| 🌐 多语言 | 中文、英文、日语、韩语、法语(自动检测 + 手动切换) |
| 📱 iOS 永久免签 | PWA H5 → Safari「添加到主屏幕」无需企业证书 |

View File

@@ -16,6 +16,7 @@ A WeChat-style end-to-end encrypted instant messaging app with stateless ECDH +
| 🗑️ Zero-Knowledge Server | Server stores only ciphertext; private keys never leave the device |
| 📹 Video & Voice Calls | WebRTC P2P (1:1) + Mesh (group), Cloudflare TURN for NAT traversal |
| 👥 Group Chat | Up to 2000 members, plain-text messages (no encryption), Do Not Disturb mode, member management |
| ⏱️ Auto-Delete Messages | 5 tiers (never / 1 day / 3 days / 1 week / 1 month), settable by either party in DMs, owner-only in groups |
| 🔔 Push Notifications | Web Push (VAPID) + OneSignal dual-channel — reach users even when offline |
| 🌐 Multi-Language | Chinese, English, Japanese, Korean, French — auto-detect + manual switch |
| 📱 iOS — No Enterprise Cert | PWA via Safari "Add to Home Screen", works permanently without Apple signing |

View File

@@ -65,6 +65,8 @@ export const api = {
leaveGroup: id => req('DELETE', `/api/groups/${id}/members/me`),
disbandGroup: id => req('DELETE', `/api/groups/${id}`),
muteGroup: (id, muted) => req('PATCH', `/api/groups/${id}/mute`, { muted }),
setAutoDelete: (friendId, val) => req('PATCH', `/api/friends/${friendId}/auto-delete`, { auto_delete: val }),
setGroupAutoDelete: (groupId, val) => req('PATCH', `/api/groups/${groupId}/auto-delete`, { auto_delete: val }),
// Messages
privateHistory: pid => req('GET', `/api/messages/private/${pid}`),

View File

@@ -282,6 +282,17 @@ function setupGlobalSocketHandlers() {
}
});
// ── Auto-delete setting changed by other party ───────────────────────
onEvent('auto_delete_changed', ({ chat_id, chat_type, auto_delete }) => {
if (chat_type === 'private') {
const contact = state.contacts.find(c => c.id === chat_id);
if (contact) contact.auto_delete = auto_delete;
} else {
const group = (state.groupsList || []).find(g => g.id === chat_id);
if (group) group.auto_delete = auto_delete;
}
});
// ── Session Revoked (device kicked by another session) ───────────────
onEvent('session_revoked', () => {
clearToken();

View File

@@ -184,6 +184,14 @@ const TRANSLATIONS = {
muteGroup: '消息免打扰',
muteEnabled: '已开启免打扰',
muteDisabled: '已关闭免打扰',
autoDeleteTitle: '自动删除消息',
autoDeleteNever: '永不删除',
autoDelete1d: '1 天后',
autoDelete3d: '3 天后',
autoDelete7d: '1 周后',
autoDelete30d: '1 个月后',
autoDeleteUpdated: '自动删除已更新',
autoDeleteOwnerOnly: '只有群主可以设置',
},
en: {
@@ -358,6 +366,14 @@ const TRANSLATIONS = {
muteGroup: 'Mute Notifications',
muteEnabled: 'Notifications muted',
muteDisabled: 'Notifications unmuted',
autoDeleteTitle: 'Auto-Delete Messages',
autoDeleteNever: 'Never',
autoDelete1d: 'After 1 Day',
autoDelete3d: 'After 3 Days',
autoDelete7d: 'After 1 Week',
autoDelete30d: 'After 1 Month',
autoDeleteUpdated: 'Auto-delete updated',
autoDeleteOwnerOnly: 'Only owner can set',
},
ja: {
@@ -532,6 +548,14 @@ const TRANSLATIONS = {
muteGroup: '通知ミュート',
muteEnabled: '通知をミュートしました',
muteDisabled: '通知のミュートを解除しました',
autoDeleteTitle: 'メッセージ自动削除',
autoDeleteNever: '削除しない',
autoDelete1d: '1日後',
autoDelete3d: '3日後',
autoDelete7d: '1週間後',
autoDelete30d: '1か月後',
autoDeleteUpdated: '自动削除が更新されました',
autoDeleteOwnerOnly: 'オーナーのみ設定可',
},
ko: {
@@ -706,6 +730,14 @@ const TRANSLATIONS = {
muteGroup: '알림 음소거',
muteEnabled: '알림이 음소거되었습니다',
muteDisabled: '알림 음소거가 해제되었습니다',
autoDeleteTitle: '메시지 자동 삭제',
autoDeleteNever: '삭제 안 함',
autoDelete1d: '1일 후',
autoDelete3d: '3일 후',
autoDelete7d: '1주일 후',
autoDelete30d: '1개월 후',
autoDeleteUpdated: '자동 삭제가 업데이트되었습니다',
autoDeleteOwnerOnly: '그룹장만 설정 가능',
},
fr: {
@@ -880,6 +912,14 @@ const TRANSLATIONS = {
muteGroup: 'Mettre en sourdine',
muteEnabled: 'Notifications désactivées',
muteDisabled: 'Notifications réactivées',
autoDeleteTitle: 'Suppression auto des messages',
autoDeleteNever: 'Jamais',
autoDelete1d: 'Après 1 jour',
autoDelete3d: 'Après 3 jours',
autoDelete7d: 'Après 1 semaine',
autoDelete30d: 'Après 1 mois',
autoDeleteUpdated: 'Suppression auto mise à jour',
autoDeleteOwnerOnly: 'Seul le propriétaire peut définir',
},
};

View File

@@ -32,6 +32,12 @@ export async function renderChat(root, chat) {
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>` : ''}
${chat.type === 'private' ? `
<button class="topbar-btn" id="auto-delete-btn" title="${t('autoDeleteTitle')}">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>
</svg>
</button>` : ''}
<button class="topbar-btn" id="voice-call-btn" title="${t('callVoice')}">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M6.6 10.8c1.4 2.8 3.8 5.1 6.6 6.6l2.2-2.2c.27-.27.67-.36 1.02-.23 1.12.45 2.34.68 3.58.68.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.3 21 3 13.7 3 4.5c0-.55.45-1 1-1H8c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.1.35.02.74-.22 1.02L6.6 10.8z"/>
@@ -53,6 +59,19 @@ export async function renderChat(root, chat) {
openGroupInfo(chat.id);
});
// Auto-delete button (private chats)
topbar.querySelector('#auto-delete-btn')?.addEventListener('click', () => {
const contact = state.contacts.find(c => c.id === chat.id);
const current = contact?.auto_delete ?? 604800;
showAutoDeletePicker(current, async (val) => {
try {
await api.setAutoDelete(chat.id, val);
if (contact) contact.auto_delete = val;
showToast(t('autoDeleteUpdated'));
} catch (err) { showToast(err.message); }
});
});
// Call buttons
topbar.querySelector('#voice-call-btn').onclick = async () => {
if (callManager.state !== 'idle') { showToast(t('callBusy')); return; }
@@ -531,3 +550,44 @@ function showImageViewer(src) {
viewer.onclick = e => { if (e.target === viewer) viewer.remove(); };
document.body.appendChild(viewer);
}
function showAutoDeletePicker(current, onSelect) {
const options = [
{ value: 0, label: t('autoDeleteNever') },
{ value: 86400, label: t('autoDelete1d') },
{ value: 259200, label: t('autoDelete3d') },
{ value: 604800, label: t('autoDelete7d') },
{ value: 2592000, label: t('autoDelete30d') },
];
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal-card" style="width:85%;max-width:360px;">
<div class="modal-header">
<div style="min-width:44px"></div>
<div class="topbar-title" style="font-size:16px">${t('autoDeleteTitle')}</div>
<div style="min-width:44px"></div>
</div>
<div id="ad-options" style="padding:8px 0;"></div>
</div>
`;
document.body.appendChild(overlay);
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
const list = overlay.querySelector('#ad-options');
options.forEach(opt => {
const row = document.createElement('div');
row.className = 'list-item';
row.style.cssText = 'cursor:pointer;justify-content:space-between;padding:14px 20px;';
const isActive = current === opt.value;
row.innerHTML = `
<span style="font-size:15px;font-weight:${isActive ? '600' : '400'};color:${isActive ? 'var(--green)' : 'var(--text)'};">${opt.label}</span>
${isActive ? '<svg viewBox="0 0 24 24" width="20" height="20" fill="var(--green)"><path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/></svg>' : ''}
`;
row.onclick = () => {
overlay.remove();
if (opt.value !== current) onSelect(opt.value);
};
list.appendChild(row);
});
}

View File

@@ -116,6 +116,48 @@ export async function renderGroupInfo(root, groupId) {
muteSection.appendChild(muteRow);
content.appendChild(muteSection);
// ── Auto-Delete Setting ──
const autoDeleteLabels = {
0: t('autoDeleteNever'), 86400: t('autoDelete1d'),
259200: t('autoDelete3d'), 604800: t('autoDelete7d'),
2592000: t('autoDelete30d'),
};
const groupAutoDelete = info.auto_delete ?? 604800;
const adSection = document.createElement('div');
adSection.className = 'settings-section';
const adRow = document.createElement('div');
adRow.className = 'list-item';
adRow.style.cssText = isOwner ? 'cursor:pointer;' : '';
adRow.innerHTML = `
<div style="flex:1;display:flex;align-items:center;gap:10px;">
<svg viewBox="0 0 24 24" width="20" height="20" fill="var(--text-muted)">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>
</svg>
<span style="font-size:15px;font-weight:500;">${t('autoDeleteTitle')}</span>
</div>
<span id="ad-value" class="text-muted" style="font-size:14px;">${autoDeleteLabels[groupAutoDelete] || autoDeleteLabels[604800]}</span>
`;
if (isOwner) {
adRow.onclick = () => {
showGroupAutoDeletePicker(groupAutoDelete, async (val) => {
try {
await api.setGroupAutoDelete(groupId, val);
adRow.querySelector('#ad-value').textContent = autoDeleteLabels[val];
showToast(t('autoDeleteUpdated'));
} catch (err) { showToast(err.message); }
});
};
}
adSection.appendChild(adRow);
if (!isOwner) {
const hint = document.createElement('div');
hint.style.cssText = 'padding:4px 16px 8px;font-size:12px;color:var(--text-muted);';
hint.textContent = t('autoDeleteOwnerOnly');
adSection.appendChild(hint);
}
content.appendChild(adSection);
// ── Members ──
const membersSection = document.createElement('div');
membersSection.className = 'settings-section';
@@ -269,3 +311,44 @@ function showAddMemberModal(groupId, existingIds, contentEl, membersSection) {
pickerEl.appendChild(item);
});
}
function showGroupAutoDeletePicker(current, onSelect) {
const options = [
{ value: 0, label: t('autoDeleteNever') },
{ value: 86400, label: t('autoDelete1d') },
{ value: 259200, label: t('autoDelete3d') },
{ value: 604800, label: t('autoDelete7d') },
{ value: 2592000, label: t('autoDelete30d') },
];
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal-card" style="width:85%;max-width:360px;">
<div class="modal-header">
<div style="min-width:44px"></div>
<div class="topbar-title" style="font-size:16px">${t('autoDeleteTitle')}</div>
<div style="min-width:44px"></div>
</div>
<div id="ad-options" style="padding:8px 0;"></div>
</div>
`;
document.body.appendChild(overlay);
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
const list = overlay.querySelector('#ad-options');
options.forEach(opt => {
const row = document.createElement('div');
row.className = 'list-item';
row.style.cssText = 'cursor:pointer;justify-content:space-between;padding:14px 20px;';
const isActive = current === opt.value;
row.innerHTML = `
<span style="font-size:15px;font-weight:${isActive ? '600' : '400'};color:${isActive ? 'var(--green)' : 'var(--text)'};">${opt.label}</span>
${isActive ? '<svg viewBox="0 0 24 24" width="20" height="20" fill="var(--green)"><path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/></svg>' : ''}
`;
row.onclick = () => {
overlay.remove();
if (opt.value !== current) onSelect(opt.value);
};
list.appendChild(row);
});
}

View File

@@ -46,12 +46,23 @@ CREATE TABLE IF NOT EXISTS friends (
user_id VARCHAR(36) NOT NULL,
friend_id VARCHAR(36) NOT NULL,
status ENUM('pending','accepted','blocked') NOT NULL DEFAULT 'pending',
auto_delete INT NOT NULL DEFAULT 604800,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_pair (user_id, friend_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (friend_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migration: add auto_delete to friends (idempotent)
SET @f_ad = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'friends' AND COLUMN_NAME = 'auto_delete');
SET @f_sql = IF(@f_ad = 0,
'ALTER TABLE friends ADD COLUMN auto_delete INT NOT NULL DEFAULT 604800 AFTER status',
'SELECT 1');
PREPARE f_stmt FROM @f_sql;
EXECUTE f_stmt;
DEALLOCATE PREPARE f_stmt;
-- ── Groups ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS `groups` (
id VARCHAR(36) PRIMARY KEY,
@@ -59,10 +70,21 @@ CREATE TABLE IF NOT EXISTS `groups` (
avatar VARCHAR(512) DEFAULT NULL,
owner_id VARCHAR(36) NOT NULL,
notice TEXT DEFAULT NULL,
auto_delete INT NOT NULL DEFAULT 604800,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migration: add auto_delete to groups (idempotent)
SET @g_ad = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'groups' AND COLUMN_NAME = 'auto_delete');
SET @g_sql = IF(@g_ad = 0,
'ALTER TABLE `groups` ADD COLUMN auto_delete INT NOT NULL DEFAULT 604800 AFTER notice',
'SELECT 1');
PREPARE g_stmt FROM @g_sql;
EXECUTE g_stmt;
DEALLOCATE PREPARE g_stmt;
-- ── Group Members ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS group_members (
group_id VARCHAR(36) NOT NULL,

View File

@@ -37,9 +37,57 @@ async function main() {
server.listen(PORT, () => {
console.log(`🚀 PaperPhone server running on http://localhost:${PORT}`);
startMessageCleanup();
});
}
/**
* Periodic cleanup — delete expired messages based on auto_delete settings.
* Runs every hour.
*/
function startMessageCleanup() {
const INTERVAL = 60 * 60 * 1000; // 1 hour
async function cleanup() {
try {
const db = getDb();
// Delete expired private messages
const [privResult] = await db.query(`
DELETE m FROM messages m
JOIN friends f ON (
(f.user_id = m.from_id AND f.friend_id = m.to_id)
OR (f.user_id = m.to_id AND f.friend_id = m.from_id)
)
WHERE m.type = 'private'
AND f.auto_delete > 0
AND m.created_at < DATE_SUB(NOW(), INTERVAL f.auto_delete SECOND)
`);
// Delete expired group messages
const [grpResult] = await db.query(`
DELETE m FROM messages m
JOIN \`groups\` g ON g.id = m.to_id
WHERE m.type = 'group'
AND g.auto_delete > 0
AND m.created_at < DATE_SUB(NOW(), INTERVAL g.auto_delete SECOND)
`);
const total = (privResult.affectedRows || 0) + (grpResult.affectedRows || 0);
if (total > 0) {
console.log(`🗑️ Auto-deleted ${total} expired messages`);
}
} catch (err) {
console.error('Message cleanup error:', err.message);
}
}
// Run once on startup, then every hour
cleanup();
setInterval(cleanup, INTERVAL);
console.log('⏱️ Message auto-delete cleanup scheduled (every 1h)');
}
main().catch(err => {
console.error('Failed to start server:', err);
process.exit(1);

View File

@@ -13,7 +13,7 @@ router.get('/', async (req, res, next) => {
try {
const db = getDb();
const [rows] = await db.query(
`SELECT u.id, u.username, u.nickname, u.avatar, u.is_online, u.last_seen
`SELECT u.id, u.username, u.nickname, u.avatar, u.is_online, u.last_seen, f.auto_delete
FROM friends f
JOIN users u ON u.id = f.friend_id
WHERE f.user_id = ? AND f.status = 'accepted'`,
@@ -98,4 +98,28 @@ router.delete('/:id', async (req, res, next) => {
} catch (err) { next(err); }
});
// PATCH /api/friends/:id/auto-delete — set auto-delete timer for private chat
router.patch('/:id/auto-delete', async (req, res, next) => {
try {
const allowed = [0, 86400, 259200, 604800, 2592000];
const val = parseInt(req.body.auto_delete);
if (!allowed.includes(val)) return res.status(400).json({ error: 'Invalid auto_delete value' });
const db = getDb();
// Update both directions of the friendship
await db.query(
`UPDATE friends SET auto_delete = ? WHERE
(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)`,
[val, req.user.id, req.params.id, req.params.id, req.user.id]
);
// Notify the other party via WS
sendToUser(req.params.id, {
type: 'auto_delete_changed',
chat_id: req.user.id,
chat_type: 'private',
auto_delete: val,
});
res.json({ ok: true, auto_delete: val });
} catch (err) { next(err); }
});
module.exports = router;

View File

@@ -14,7 +14,7 @@ router.get('/', async (req, res, next) => {
try {
const db = getDb();
const [rows] = await db.query(
`SELECT g.id, g.name, g.avatar, g.notice, g.owner_id, gm.role, gm.muted,
`SELECT g.id, g.name, g.avatar, g.notice, g.owner_id, g.auto_delete, gm.role, gm.muted,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
FROM group_members gm
JOIN \`groups\` g ON g.id = gm.group_id
@@ -189,6 +189,29 @@ router.delete('/:id', async (req, res, next) => {
} catch (err) { next(err); }
});
// PATCH /api/groups/:id/auto-delete — set auto-delete timer (owner only)
router.patch('/:id/auto-delete', async (req, res, next) => {
try {
const allowed = [0, 86400, 259200, 604800, 2592000];
const val = parseInt(req.body.auto_delete);
if (!allowed.includes(val)) return res.status(400).json({ error: 'Invalid auto_delete value' });
const db = getDb();
const [groups] = await db.query('SELECT owner_id FROM `groups` WHERE id = ?', [req.params.id]);
if (!groups.length) return res.status(404).json({ error: 'Group not found' });
if (groups[0].owner_id !== req.user.id) {
return res.status(403).json({ error: 'Only the owner can set auto-delete' });
}
await db.query('UPDATE `groups` SET auto_delete = ? WHERE id = ?', [val, req.params.id]);
await sendToGroup(req.params.id, {
type: 'auto_delete_changed',
chat_id: req.params.id,
chat_type: 'group',
auto_delete: val,
});
res.json({ ok: true, auto_delete: val });
} catch (err) { next(err); }
});
// PATCH /api/groups/:id/mute — toggle mute (any member)
router.patch('/:id/mute', async (req, res, next) => {
try {