mirror of
https://github.com/ancsemi/Haven
synced 2026-04-21 13:37:41 +00:00
1128 lines
43 KiB
JavaScript
1128 lines
43 KiB
JavaScript
export default {
|
|
|
|
// ── Mark-Read Helper ──────────────────────────────────
|
|
// ═══════════════════════════════════════════════════════
|
|
|
|
_markRead(messageId) {
|
|
if (!this.currentChannel || !messageId) return;
|
|
// Debounce: don't spam the server
|
|
clearTimeout(this._markReadTimer);
|
|
this._markReadTimer = setTimeout(() => {
|
|
this.socket.emit('mark-read', { code: this.currentChannel, messageId });
|
|
}, 500);
|
|
},
|
|
|
|
// ── Update Checker ─────────────────────────────────────
|
|
async _checkForUpdates() {
|
|
try {
|
|
// Get local version from the server
|
|
const localRes = await fetch('/api/version');
|
|
if (!localRes.ok) return;
|
|
const { version: localVersion } = await localRes.json();
|
|
|
|
// Check GitHub for latest release
|
|
const ghRes = await fetch('https://api.github.com/repos/ancsemi/Haven/releases/latest', {
|
|
headers: { Accept: 'application/vnd.github.v3+json' }
|
|
});
|
|
if (!ghRes.ok) return;
|
|
const release = await ghRes.json();
|
|
|
|
const remoteVersion = (release.tag_name || '').replace(/^v/, '');
|
|
if (!remoteVersion || !localVersion) return;
|
|
|
|
if (this._isNewerVersion(remoteVersion, localVersion)) {
|
|
// Cache the update info so visibility can be toggled without re-fetching
|
|
const zipAsset = (release.assets || []).find(a => a.name && a.name.endsWith('.zip'));
|
|
this._pendingUpdate = {
|
|
text: t('header.update_text', { version: remoteVersion }),
|
|
title: t('header.update_title', { remote: remoteVersion, local: localVersion }),
|
|
href: zipAsset ? zipAsset.browser_download_url : release.html_url
|
|
};
|
|
this._applyUpdateBanner();
|
|
}
|
|
} catch (e) {
|
|
// Silently fail — update check is non-critical
|
|
}
|
|
|
|
// Re-check every 30 minutes
|
|
setTimeout(() => this._checkForUpdates(), 30 * 60 * 1000);
|
|
},
|
|
|
|
/**
|
|
* Show or hide the update banner based on cached update info and the
|
|
* update_banner_admin_only server setting.
|
|
*/
|
|
_applyUpdateBanner() {
|
|
const banner = document.getElementById('update-banner');
|
|
if (!banner) return;
|
|
if (!this._pendingUpdate) return; // no update detected yet
|
|
|
|
const adminOnly = this.serverSettings?.update_banner_admin_only === 'true';
|
|
const canSee = !adminOnly || this.user?.isAdmin;
|
|
|
|
if (canSee) {
|
|
banner.style.display = 'inline-flex';
|
|
banner.querySelector('.update-text').textContent = this._pendingUpdate.text;
|
|
banner.title = this._pendingUpdate.title;
|
|
banner.href = this._pendingUpdate.href;
|
|
} else {
|
|
banner.style.display = 'none';
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Compare semver strings. Returns true if remote > local.
|
|
*/
|
|
_isNewerVersion(remote, local) {
|
|
const r = remote.split('.').map(Number);
|
|
const l = local.split('.').map(Number);
|
|
for (let i = 0; i < Math.max(r.length, l.length); i++) {
|
|
const rv = r[i] || 0;
|
|
const lv = l[i] || 0;
|
|
if (rv > lv) return true;
|
|
if (rv < lv) return false;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
// ── Desktop App Banner + Promo Popup ────────────────────
|
|
/** Show the "Get the Desktop App" banner and promo popup unless the user
|
|
* dismissed them or is already running inside Haven Desktop (Electron). */
|
|
_initDesktopAppBanner() {
|
|
// Don't show if already in the desktop app
|
|
if (window.havenDesktop || navigator.userAgent.includes('Electron')) return;
|
|
|
|
// Don't show on mobile / tablet — desktop app isn't relevant there
|
|
if (/Android|iPhone|iPad|iPod|Mobile|Tablet/i.test(navigator.userAgent)) return;
|
|
|
|
// ── Top-bar banner ──
|
|
const bannerDismissed = localStorage.getItem('haven_desktop_banner_dismissed');
|
|
if (!bannerDismissed) {
|
|
const banner = document.getElementById('desktop-app-banner');
|
|
if (banner) {
|
|
banner.style.display = 'inline-flex';
|
|
const dismissBtn = document.getElementById('desktop-app-dismiss');
|
|
if (dismissBtn) {
|
|
dismissBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
banner.style.display = 'none';
|
|
localStorage.setItem('haven_desktop_banner_dismissed', '1');
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Promo popup (centred modal) ──
|
|
if (localStorage.getItem('haven_desktop_promo_dismissed')) return;
|
|
|
|
const modal = document.getElementById('desktop-promo-modal');
|
|
if (!modal) return;
|
|
|
|
// Detect platform for meta line
|
|
const meta = document.getElementById('desktop-promo-meta');
|
|
if (meta) {
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
let platform = 'Desktop';
|
|
if (ua.includes('win')) platform = 'Windows Installer';
|
|
else if (ua.includes('linux')) platform = 'Linux Installer';
|
|
else if (ua.includes('mac')) platform = 'macOS Installer';
|
|
meta.textContent = `${platform} \u2022 v1.0.0`;
|
|
}
|
|
|
|
// Show after a short delay so the app finishes loading first
|
|
setTimeout(() => { modal.style.display = 'flex'; }, 1200);
|
|
|
|
// "Maybe later" closes without remembering
|
|
const laterBtn = document.getElementById('desktop-promo-later');
|
|
if (laterBtn) {
|
|
laterBtn.addEventListener('click', () => {
|
|
const check = document.getElementById('desktop-promo-dismiss-check');
|
|
if (check && check.checked) {
|
|
localStorage.setItem('haven_desktop_promo_dismissed', '1');
|
|
// Also dismiss the banner if they chose "don't show again"
|
|
localStorage.setItem('haven_desktop_banner_dismissed', '1');
|
|
const banner = document.getElementById('desktop-app-banner');
|
|
if (banner) banner.style.display = 'none';
|
|
}
|
|
modal.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
// "Install Haven" link — if checkbox checked, remember dismissal
|
|
const installBtn = document.getElementById('desktop-promo-install');
|
|
if (installBtn) {
|
|
installBtn.addEventListener('click', () => {
|
|
const check = document.getElementById('desktop-promo-dismiss-check');
|
|
if (check && check.checked) {
|
|
localStorage.setItem('haven_desktop_promo_dismissed', '1');
|
|
localStorage.setItem('haven_desktop_banner_dismissed', '1');
|
|
const banner = document.getElementById('desktop-app-banner');
|
|
if (banner) banner.style.display = 'none';
|
|
}
|
|
modal.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
// Close on overlay click — respect "don't show again" checkbox
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
const check = document.getElementById('desktop-promo-dismiss-check');
|
|
if (check && check.checked) {
|
|
localStorage.setItem('haven_desktop_promo_dismissed', '1');
|
|
localStorage.setItem('haven_desktop_banner_dismissed', '1');
|
|
const banner = document.getElementById('desktop-app-banner');
|
|
if (banner) banner.style.display = 'none';
|
|
}
|
|
modal.style.display = 'none';
|
|
}
|
|
});
|
|
},
|
|
|
|
// ── Android Beta Banner + Sign-Up Popup ─────────────────
|
|
/** Show the "Android Beta" banner and sign-up popup. Users enter their email
|
|
* and a prefilled mailto: link sends the opt-in request to the developer. */
|
|
_initAndroidBetaBanner() {
|
|
// ── v3 migration: Android app is now a full release; reset dismissals so
|
|
// users who dismissed the old closed-beta popup see the new announcement ──
|
|
if (!localStorage.getItem('_ab_v3_migrated')) {
|
|
localStorage.removeItem('haven_android_beta_banner_dismissed');
|
|
localStorage.removeItem('haven_android_beta_promo_dismissed');
|
|
localStorage.removeItem('haven_ab_banner_nodisplay');
|
|
localStorage.removeItem('haven_ab_promo_nodisplay');
|
|
localStorage.setItem('_ab_v3_migrated', '1');
|
|
}
|
|
|
|
// ── Top-bar banner ──
|
|
// Only permanently hidden if user checked "Don't show this again";
|
|
// the X button is session-only so it returns on next visit.
|
|
const permaDismissed = localStorage.getItem('haven_ab_banner_nodisplay');
|
|
const sessionDismissed = sessionStorage.getItem('haven_ab_banner_session');
|
|
if (!permaDismissed && !sessionDismissed) {
|
|
const banner = document.getElementById('android-beta-banner');
|
|
if (banner) {
|
|
banner.style.display = 'inline-flex';
|
|
banner.addEventListener('click', (e) => {
|
|
// Don't open modal if dismiss button was clicked
|
|
if (e.target.closest('.android-beta-dismiss')) return;
|
|
const modal = document.getElementById('android-beta-modal');
|
|
if (modal) modal.style.display = 'flex';
|
|
});
|
|
const dismissBtn = document.getElementById('android-beta-dismiss');
|
|
if (dismissBtn) {
|
|
dismissBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
banner.style.display = 'none';
|
|
// Session-only: banner comes back on next page load
|
|
sessionStorage.setItem('haven_ab_banner_session', '1');
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Promo popup (centred modal) ──
|
|
const modal = document.getElementById('android-beta-modal');
|
|
if (!modal) return;
|
|
|
|
// Show popup on first visit (unless dismissed)
|
|
if (!localStorage.getItem('haven_ab_promo_nodisplay')) {
|
|
setTimeout(() => {
|
|
// Don't show if the desktop promo is already visible
|
|
const desktopPromo = document.getElementById('desktop-promo-modal');
|
|
if (desktopPromo && desktopPromo.style.display === 'flex') {
|
|
// Show after the desktop promo closes
|
|
const observer = new MutationObserver(() => {
|
|
if (desktopPromo.style.display === 'none' || desktopPromo.style.display === '') {
|
|
observer.disconnect();
|
|
setTimeout(() => { modal.style.display = 'flex'; }, 800);
|
|
}
|
|
});
|
|
observer.observe(desktopPromo, { attributes: true, attributeFilter: ['style'] });
|
|
} else {
|
|
modal.style.display = 'flex';
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
// Close modal when user clicks the beta access link
|
|
const submitBtn = document.getElementById('android-beta-submit');
|
|
if (submitBtn) {
|
|
submitBtn.addEventListener('click', () => {
|
|
localStorage.setItem('haven_ab_promo_nodisplay', '1');
|
|
modal.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
// "Maybe later" button
|
|
const laterBtn = document.getElementById('android-beta-later');
|
|
if (laterBtn) {
|
|
laterBtn.addEventListener('click', () => {
|
|
const check = document.getElementById('android-beta-dismiss-check');
|
|
if (check && check.checked) {
|
|
localStorage.setItem('haven_ab_promo_nodisplay', '1');
|
|
localStorage.setItem('haven_ab_banner_nodisplay', '1');
|
|
const banner = document.getElementById('android-beta-banner');
|
|
if (banner) banner.style.display = 'none';
|
|
}
|
|
modal.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
// Close on overlay click — respect "don't show again" checkbox
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
const check = document.getElementById('android-beta-dismiss-check');
|
|
if (check && check.checked) {
|
|
localStorage.setItem('haven_ab_promo_nodisplay', '1');
|
|
localStorage.setItem('haven_ab_banner_nodisplay', '1');
|
|
const banner = document.getElementById('android-beta-banner');
|
|
if (banner) banner.style.display = 'none';
|
|
}
|
|
modal.style.display = 'none';
|
|
}
|
|
});
|
|
},
|
|
|
|
async _setupDesktopShortcuts() {
|
|
if (!window.havenDesktop?.shortcuts) return;
|
|
// Guard against duplicate listener attachment (called each time the nav item is clicked)
|
|
if (this._desktopShortcutsReady) return;
|
|
this._desktopShortcutsReady = true;
|
|
|
|
const keyMap = {
|
|
' ': 'Space', 'ArrowUp': 'Up', 'ArrowDown': 'Down',
|
|
'ArrowLeft': 'Left', 'ArrowRight': 'Right',
|
|
'Escape': 'Escape', 'Tab': 'Tab', 'Enter': 'Return',
|
|
'Backspace': 'Backspace', 'Delete': 'Delete',
|
|
'Home': 'Home', 'End': 'End', 'PageUp': 'PageUp', 'PageDown': 'PageDown',
|
|
};
|
|
|
|
const formatAccel = (accel) => {
|
|
if (!accel) return '—';
|
|
return accel.replace('CommandOrControl', 'Ctrl/Cmd').replace('Control', 'Ctrl');
|
|
};
|
|
|
|
let config = {};
|
|
try { config = await window.havenDesktop.shortcuts.getConfig(); } catch (e) {}
|
|
|
|
const actions = ['mute', 'deafen', 'ptt'];
|
|
|
|
actions.forEach(action => {
|
|
const keyEl = document.getElementById(`shortcut-key-${action}`);
|
|
const recordBtn = document.querySelector(`.shortcut-record-btn[data-action="${action}"]`);
|
|
const clearBtn = document.querySelector(`.shortcut-clear-btn[data-action="${action}"]`);
|
|
if (!keyEl || !recordBtn || !clearBtn) return;
|
|
|
|
keyEl.textContent = formatAccel(config[action] || '');
|
|
|
|
recordBtn.addEventListener('click', () => {
|
|
// Already recording — cancel
|
|
if (recordBtn.classList.contains('recording')) {
|
|
recordBtn.classList.remove('recording');
|
|
recordBtn.textContent = 'Record';
|
|
keyEl.classList.remove('recording-label');
|
|
// Re-register shortcuts after cancelling recording
|
|
window.havenDesktop.shortcuts.setConfig({}).catch(() => {});
|
|
return;
|
|
}
|
|
recordBtn.classList.add('recording');
|
|
recordBtn.textContent = 'Press key…';
|
|
keyEl.classList.add('recording-label');
|
|
keyEl.textContent = '…';
|
|
|
|
// Temporarily clear the shortcut being recorded so its global hotkey
|
|
// doesn't swallow the keystroke before the BrowserView sees it
|
|
window.havenDesktop.shortcuts.setConfig({ [action]: '' }).catch(() => {});
|
|
|
|
const onKeyDown = async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// Ignore lone modifiers
|
|
if (['Control', 'Meta', 'Alt', 'Shift'].includes(e.key)) return;
|
|
|
|
const parts = [];
|
|
if (e.ctrlKey || e.metaKey) parts.push('CommandOrControl');
|
|
if (e.altKey) parts.push('Alt');
|
|
if (e.shiftKey) parts.push('Shift');
|
|
const mapped = keyMap[e.key] || (e.key.length === 1 ? e.key.toUpperCase() : e.key);
|
|
parts.push(mapped);
|
|
const accel = parts.join('+');
|
|
|
|
document.removeEventListener('keydown', onKeyDown, true);
|
|
recordBtn.classList.remove('recording');
|
|
recordBtn.textContent = 'Record';
|
|
keyEl.classList.remove('recording-label');
|
|
|
|
try {
|
|
await window.havenDesktop.shortcuts.setConfig({ [action]: accel });
|
|
config[action] = accel;
|
|
keyEl.textContent = formatAccel(accel);
|
|
} catch (err) {
|
|
// Restore previous shortcut
|
|
await window.havenDesktop.shortcuts.setConfig({ [action]: config[action] || '' }).catch(() => {});
|
|
keyEl.textContent = formatAccel(config[action] || '');
|
|
this._showToast?.('Failed to register shortcut — it may already be in use.', 'error');
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', onKeyDown, true);
|
|
});
|
|
|
|
clearBtn.addEventListener('click', async () => {
|
|
try {
|
|
await window.havenDesktop.shortcuts.setConfig({ [action]: '' });
|
|
config[action] = '';
|
|
keyEl.textContent = '—';
|
|
} catch (err) {}
|
|
});
|
|
});
|
|
},
|
|
|
|
/* ── Desktop App Preferences (start on login, tray, SDR) ── */
|
|
|
|
async _setupDesktopAppPrefs() {
|
|
if (!window.havenDesktop?.prefs) return;
|
|
if (this._desktopPrefsReady) return;
|
|
this._desktopPrefsReady = true;
|
|
|
|
let prefs = {};
|
|
try { prefs = await window.havenDesktop.prefs.get(); } catch {}
|
|
|
|
const startEl = document.getElementById('pref-start-on-login');
|
|
const hiddenEl = document.getElementById('pref-start-hidden');
|
|
const hiddenRow = document.getElementById('pref-start-hidden-row');
|
|
const trayEl = document.getElementById('pref-minimize-to-tray');
|
|
const sdrEl = document.getElementById('pref-force-sdr');
|
|
const menuBarEl = document.getElementById('pref-hide-menu-bar');
|
|
const versionEl = document.getElementById('desktop-version-info');
|
|
|
|
if (startEl) { startEl.checked = !!prefs.startOnLogin; }
|
|
if (hiddenEl) { hiddenEl.checked = !!prefs.startHidden; }
|
|
if (hiddenRow) { hiddenRow.style.display = prefs.startOnLogin ? '' : 'none'; }
|
|
if (trayEl) { trayEl.checked = !!prefs.minimizeToTray; }
|
|
if (sdrEl) { sdrEl.checked = !!prefs.forceSDR; }
|
|
if (menuBarEl) { menuBarEl.checked = !!prefs.hideMenuBar; }
|
|
|
|
// Show desktop version
|
|
if (versionEl && window.havenDesktop.getVersion) {
|
|
try {
|
|
const v = await window.havenDesktop.getVersion();
|
|
versionEl.textContent = `Haven Desktop v${v}`;
|
|
} catch {}
|
|
}
|
|
|
|
startEl?.addEventListener('change', async () => {
|
|
try { await window.havenDesktop.prefs.setStartOnLogin(startEl.checked); }
|
|
catch { startEl.checked = !startEl.checked; }
|
|
// Show/hide the start-hidden option
|
|
if (hiddenRow) hiddenRow.style.display = startEl.checked ? '' : 'none';
|
|
});
|
|
|
|
hiddenEl?.addEventListener('change', async () => {
|
|
try { await window.havenDesktop.prefs.setStartHidden(hiddenEl.checked); }
|
|
catch { hiddenEl.checked = !hiddenEl.checked; }
|
|
});
|
|
|
|
trayEl?.addEventListener('change', async () => {
|
|
try { await window.havenDesktop.prefs.setMinimizeToTray(trayEl.checked); }
|
|
catch { trayEl.checked = !trayEl.checked; }
|
|
});
|
|
|
|
sdrEl?.addEventListener('change', async () => {
|
|
try {
|
|
const res = await window.havenDesktop.prefs.setForceSDR(sdrEl.checked);
|
|
if (res?.requiresRestart) {
|
|
this._showToast('Color profile updated. Restart Haven Desktop to apply.', 'info');
|
|
}
|
|
} catch { sdrEl.checked = !sdrEl.checked; }
|
|
});
|
|
|
|
menuBarEl?.addEventListener('change', async () => {
|
|
try { await window.havenDesktop.prefs.setHideMenuBar(menuBarEl.checked); }
|
|
catch { menuBarEl.checked = !menuBarEl.checked; }
|
|
});
|
|
},
|
|
|
|
/* ── E2E Encryption Helpers ──────────────────────────── */
|
|
|
|
async _initE2E() {
|
|
if (typeof HavenE2E === 'undefined') return;
|
|
try {
|
|
this.e2e = new HavenE2E();
|
|
// Read the password-derived wrapping key from sessionStorage (set during login).
|
|
// On auto-login (JWT, no password) this will be null — IndexedDB-only mode.
|
|
const wrappingKey = sessionStorage.getItem('haven_e2e_wrap') || null;
|
|
const ok = await this.e2e.init(this.socket, wrappingKey);
|
|
// Keep wrapping key in memory for cross-device sync (conflict resolution).
|
|
// Clear from sessionStorage but retain privately for backup restoration.
|
|
// Also persist to localStorage so server list sync works across page reloads.
|
|
if (wrappingKey) {
|
|
this._e2eWrappingKey = wrappingKey;
|
|
sessionStorage.removeItem('haven_e2e_wrap');
|
|
try { localStorage.setItem('haven_sync_key', wrappingKey); } catch { /* private mode */ }
|
|
} else {
|
|
// On auto-login (no password), recover the sync key from localStorage
|
|
try {
|
|
const savedKey = localStorage.getItem('haven_sync_key');
|
|
if (savedKey) this._e2eWrappingKey = savedKey;
|
|
} catch { /* ignore */ }
|
|
}
|
|
if (ok) {
|
|
await this._e2eSetupListeners();
|
|
// If keys were auto-reset during init (backup unwrap failed), notify
|
|
if (this.e2e.keysWereReset) {
|
|
setTimeout(() => {
|
|
this._appendE2ENotice(`🔄 Encryption keys were regenerated — ${new Date().toLocaleString()}. Previous encrypted messages may no longer be decryptable.`);
|
|
}, 500);
|
|
}
|
|
} else {
|
|
console.warn('[E2E] Init returned false — encryption unavailable');
|
|
// Don't null out e2e if server backup exists — we may sync later
|
|
if (!this.e2e._serverBackupExists) this.e2e = null;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[E2E] Init failed:', err);
|
|
this.e2e = null;
|
|
}
|
|
|
|
// Sync server list with server-side encrypted backup (piggybacks on wrapping key)
|
|
try {
|
|
const syncKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
|
|
if (syncKey && this.serverManager) {
|
|
await this.serverManager.syncWithServer(this.token, syncKey);
|
|
this._renderServerBar();
|
|
this._pushServersToDesktopHistory();
|
|
|
|
// Re-sync periodically (every 5 min) so cross-device changes propagate
|
|
// without requiring a full page reload or re-login
|
|
if (!this._serverSyncInterval) {
|
|
this._serverSyncInterval = setInterval(async () => {
|
|
const key = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
|
|
if (key && this.serverManager && this.token) {
|
|
try {
|
|
await this.serverManager.syncWithServer(this.token, key);
|
|
this._renderServerBar();
|
|
this._pushServersToDesktopHistory();
|
|
} catch { /* silent — best-effort background sync */ }
|
|
}
|
|
}, 5 * 60 * 1000);
|
|
}
|
|
|
|
// Also sync when the tab becomes visible (user switching back from another server)
|
|
if (!this._serverSyncVisibility) {
|
|
this._serverSyncVisibility = true;
|
|
document.addEventListener('visibilitychange', async () => {
|
|
if (document.visibilityState !== 'visible') return;
|
|
const key = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
|
|
if (key && this.serverManager && this.token) {
|
|
try {
|
|
await this.serverManager.syncWithServer(this.token, key);
|
|
this._renderServerBar();
|
|
this._pushServersToDesktopHistory();
|
|
} catch { /* silent */ }
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[ServerSync] Post-login sync failed:', err.message);
|
|
}
|
|
},
|
|
|
|
/** Publish our key and wire up partner-key listeners (idempotent). */
|
|
async _e2eSetupListeners() {
|
|
// Publish our public key (force if keys were explicitly reset)
|
|
const result = await this.e2e.publishKey(this.socket, this.e2e.keysWereReset);
|
|
|
|
// Handle publish conflict: server has a different key (another device changed it).
|
|
// Sync from the server backup instead of overwriting.
|
|
if (result.conflict) {
|
|
console.warn('[E2E] Server has a different key — syncing from server backup...');
|
|
const wrappingKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
|
|
if (wrappingKey) {
|
|
const synced = await this.e2e.syncFromServer(this.socket, wrappingKey);
|
|
if (synced) {
|
|
// After sync, re-publish: the key now matches the server backup,
|
|
// so the server should accept it. Use force=true to handle the edge case
|
|
// where the public_key column differs from the encrypted backup.
|
|
await this.e2e.publishKey(this.socket, true);
|
|
this._dmPublicKeys = {};
|
|
this._showToast('Encryption keys synced from another device', 'success');
|
|
} else {
|
|
this._showToast('Could not sync encryption keys — try re-entering your password', 'error');
|
|
}
|
|
} else {
|
|
// No wrapping key — need password
|
|
this._showToast('Encryption keys changed on another device — re-enter your password to sync', 'error');
|
|
this._e2ePwPendingAction = () => this._syncE2EFromServer();
|
|
this._showE2EPasswordModal();
|
|
}
|
|
}
|
|
|
|
// Only attach socket listeners once
|
|
if (this._e2eListenersAttached) return;
|
|
this._e2eListenersAttached = true;
|
|
|
|
this.socket.on('public-key-result', (data) => {
|
|
if (!data.jwk) return;
|
|
const oldKey = this._dmPublicKeys[data.userId];
|
|
const changed = oldKey && (oldKey.x !== data.jwk.x || oldKey.y !== data.jwk.y);
|
|
this._dmPublicKeys[data.userId] = data.jwk;
|
|
|
|
if (changed && this.e2e) {
|
|
this.e2e.clearSharedKey(data.userId);
|
|
console.warn(`[E2E] Partner ${data.userId} key changed — cache invalidated`);
|
|
|
|
// Post a visible notice if we're currently viewing a DM with this partner.
|
|
// Store it so it survives the message re-render triggered by _retryDecryptForUser.
|
|
const ch = this.channels.find(c => c.code === this.currentChannel);
|
|
if (ch && ch.is_dm && ch.dm_target && ch.dm_target.id === data.userId) {
|
|
this._pendingE2ENotice = `🔄 ${ch.dm_target.username}'s encryption keys changed — ${new Date().toLocaleString()}. Previously encrypted messages may no longer be decryptable.`;
|
|
}
|
|
}
|
|
|
|
// Resolve any pending requestPartnerKey promises for this user
|
|
// (not used when e2e.requestPartnerKey handles it, but covers
|
|
// the case where _fetchDMPartnerKey fires a fire-and-forget)
|
|
this._retryDecryptForUser(data.userId);
|
|
});
|
|
|
|
console.log('[E2E] Listeners attached, key published');
|
|
|
|
// Listen for key sync from another session of the same user
|
|
this.socket.on('e2e-key-sync', async () => {
|
|
console.log('[E2E] Key changed on another session — syncing...');
|
|
const wrappingKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
|
|
if (wrappingKey && this.e2e) {
|
|
const synced = await this.e2e.syncFromServer(this.socket, wrappingKey);
|
|
if (synced) {
|
|
await this.e2e.publishKey(this.socket);
|
|
this._dmPublicKeys = {};
|
|
this._showToast('Encryption keys synced', 'success');
|
|
// Re-fetch messages if in a DM to re-decrypt
|
|
const ch = this.channels.find(c => c.code === this.currentChannel);
|
|
if (ch && ch.is_dm) {
|
|
this._oldestMsgId = null;
|
|
this._noMoreHistory = false;
|
|
this._loadingHistory = false;
|
|
this._historyBefore = null;
|
|
this._newestMsgId = null;
|
|
this._noMoreFuture = true;
|
|
this._loadingFuture = false;
|
|
this._historyAfter = null;
|
|
this.socket.emit('get-messages', { code: this.currentChannel });
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
// No wrapping key or sync failed — prompt for password
|
|
this._showToast('Encryption keys changed on another device — re-enter your password to sync', 'error');
|
|
this._e2ePwPendingAction = () => this._syncE2EFromServer();
|
|
this._showE2EPasswordModal();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Sync E2E keys from the server backup (called after password prompt or conflict detection).
|
|
*/
|
|
async _syncE2EFromServer() {
|
|
const wrappingKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
|
|
if (!wrappingKey || !this.e2e) return;
|
|
|
|
const synced = await this.e2e.syncFromServer(this.socket, wrappingKey);
|
|
if (synced) {
|
|
await this.e2e.publishKey(this.socket);
|
|
this._dmPublicKeys = {};
|
|
this._showToast('Encryption keys synced from another device', 'success');
|
|
// Re-fetch messages if in a DM
|
|
const ch = this.channels.find(c => c.code === this.currentChannel);
|
|
if (ch && ch.is_dm) {
|
|
this._oldestMsgId = null;
|
|
this._noMoreHistory = false;
|
|
this._loadingHistory = false;
|
|
this._historyBefore = null;
|
|
this._newestMsgId = null;
|
|
this._noMoreFuture = true;
|
|
this._loadingFuture = false;
|
|
this._historyAfter = null;
|
|
this.socket.emit('get-messages', { code: this.currentChannel });
|
|
}
|
|
} else {
|
|
this._showToast('Key sync failed — encryption may not work correctly', 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Require E2E to be ready before executing an action.
|
|
* If E2E isn't ready (no password was provided at login), shows the password prompt.
|
|
* @param {Function} action - Callback to run once E2E is available
|
|
*/
|
|
_requireE2E(action) {
|
|
if (this.e2e && this.e2e.ready) {
|
|
action();
|
|
return;
|
|
}
|
|
// E2E not available — prompt for password
|
|
this._e2ePwPendingAction = action;
|
|
this._showE2EPasswordModal();
|
|
},
|
|
|
|
/**
|
|
* Show the E2E password prompt modal.
|
|
*/
|
|
_showE2EPasswordModal() {
|
|
const modal = document.getElementById('e2e-password-modal');
|
|
const input = document.getElementById('e2e-pw-input');
|
|
const errorEl = document.getElementById('e2e-pw-error');
|
|
const submitBtn = document.getElementById('e2e-pw-submit-btn');
|
|
|
|
input.value = '';
|
|
errorEl.style.display = 'none';
|
|
errorEl.textContent = '';
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = 'Unlock';
|
|
|
|
// Check rate limit
|
|
const now = Date.now();
|
|
this._e2ePwAttempts = (this._e2ePwAttempts || []).filter(t => now - t < 60_000);
|
|
if (this._e2ePwAttempts.length >= 5) {
|
|
const oldest = this._e2ePwAttempts[0];
|
|
const waitSec = Math.ceil((60_000 - (now - oldest)) / 1000);
|
|
errorEl.textContent = `Too many attempts. Try again in ${waitSec}s.`;
|
|
errorEl.style.display = 'block';
|
|
submitBtn.disabled = true;
|
|
}
|
|
|
|
modal.style.display = 'flex';
|
|
setTimeout(() => input.focus(), 50);
|
|
},
|
|
|
|
/**
|
|
* Submit the E2E password prompt — verify against server, derive wrapping key, init E2E.
|
|
*/
|
|
async _submitE2EPassword() {
|
|
const modal = document.getElementById('e2e-password-modal');
|
|
const input = document.getElementById('e2e-pw-input');
|
|
const errorEl = document.getElementById('e2e-pw-error');
|
|
const submitBtn = document.getElementById('e2e-pw-submit-btn');
|
|
|
|
const password = input.value;
|
|
if (!password) {
|
|
errorEl.textContent = 'Please enter your password.';
|
|
errorEl.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
// Rate limit check
|
|
const now = Date.now();
|
|
this._e2ePwAttempts = (this._e2ePwAttempts || []).filter(t => now - t < 60_000);
|
|
if (this._e2ePwAttempts.length >= 5) {
|
|
const oldest = this._e2ePwAttempts[0];
|
|
const waitSec = Math.ceil((60_000 - (now - oldest)) / 1000);
|
|
errorEl.textContent = `Too many attempts. Try again in ${waitSec}s.`;
|
|
errorEl.style.display = 'block';
|
|
submitBtn.disabled = true;
|
|
return;
|
|
}
|
|
|
|
// Record attempt
|
|
this._e2ePwAttempts.push(now);
|
|
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = 'Verifying…';
|
|
errorEl.style.display = 'none';
|
|
|
|
try {
|
|
// Verify password on server
|
|
const resp = await fetch('/api/auth/verify-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: this.user.username, password })
|
|
});
|
|
const data = await resp.json();
|
|
|
|
if (!data.valid) {
|
|
const remaining = 5 - this._e2ePwAttempts.length;
|
|
errorEl.textContent = `Incorrect password. ${remaining > 0 ? remaining + ' attempt' + (remaining !== 1 ? 's' : '') + ' remaining.' : 'Locked out for 60s.'}`;
|
|
errorEl.style.display = 'block';
|
|
submitBtn.disabled = remaining <= 0;
|
|
submitBtn.textContent = 'Unlock';
|
|
input.value = '';
|
|
input.focus();
|
|
return;
|
|
}
|
|
|
|
// Password correct — derive wrapping key and init E2E
|
|
submitBtn.textContent = 'Unlocking…';
|
|
const wrappingKey = await HavenE2E.deriveWrappingKey(password);
|
|
sessionStorage.setItem('haven_e2e_wrap', wrappingKey);
|
|
this._e2eWrappingKey = wrappingKey;
|
|
|
|
// If a key reset is pending, skip normal init (it may fail if backup
|
|
// is encrypted with a different password). Reset generates fresh keys.
|
|
if (this._e2eResetPending) {
|
|
this._e2eResetPending = false;
|
|
this._closeE2EPasswordModal();
|
|
await this._performE2EKeyReset();
|
|
return;
|
|
}
|
|
|
|
// Re-initialize E2E with the wrapping key
|
|
if (!this.e2e) this.e2e = new HavenE2E();
|
|
const ok = await this.e2e.init(this.socket, wrappingKey);
|
|
|
|
if (ok) {
|
|
// Set up E2E listeners (handles publish + conflict resolution)
|
|
await this._e2eSetupListeners();
|
|
this._closeE2EPasswordModal();
|
|
this._showToast('Encryption unlocked', 'success');
|
|
|
|
// Execute the pending action
|
|
if (this._e2ePwPendingAction) {
|
|
const action = this._e2ePwPendingAction;
|
|
this._e2ePwPendingAction = null;
|
|
action();
|
|
}
|
|
} else {
|
|
errorEl.textContent = 'Failed to initialize encryption. Please try again.';
|
|
errorEl.style.display = 'block';
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = 'Unlock';
|
|
}
|
|
} catch (err) {
|
|
console.error('[E2E] Password prompt error:', err);
|
|
errorEl.textContent = 'An error occurred. Please try again.';
|
|
errorEl.style.display = 'block';
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = 'Unlock';
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Close the E2E password prompt modal.
|
|
*/
|
|
_closeE2EPasswordModal() {
|
|
const modal = document.getElementById('e2e-password-modal');
|
|
modal.style.display = 'none';
|
|
document.getElementById('e2e-pw-input').value = '';
|
|
this._e2ePwPendingAction = null;
|
|
this._e2eResetPending = false;
|
|
},
|
|
|
|
/**
|
|
* Get the E2E partner for the current DM channel.
|
|
* Returns { userId, publicKeyJwk } or null.
|
|
*/
|
|
_getE2EPartner() {
|
|
if (!this.e2e || !this.e2e.ready) return null;
|
|
const ch = this.channels.find(c => c.code === this.currentChannel);
|
|
if (!ch || !ch.is_dm || !ch.dm_target) return null;
|
|
const jwk = this._dmPublicKeys[ch.dm_target.id];
|
|
return jwk ? { userId: ch.dm_target.id, publicKeyJwk: jwk } : null;
|
|
},
|
|
|
|
/**
|
|
* Re-fetch messages when a partner's key arrives (fixes key/message race).
|
|
*/
|
|
_retryDecryptForUser(userId) {
|
|
const ch = this.channels.find(c => c.code === this.currentChannel);
|
|
if (!ch || !ch.is_dm || !ch.dm_target || ch.dm_target.id !== userId) return;
|
|
this._oldestMsgId = null;
|
|
this._noMoreHistory = false;
|
|
this._loadingHistory = false;
|
|
this._historyBefore = null;
|
|
this._newestMsgId = null;
|
|
this._noMoreFuture = true;
|
|
this._loadingFuture = false;
|
|
this._historyAfter = null;
|
|
this.socket.emit('get-messages', { code: this.currentChannel });
|
|
},
|
|
|
|
/**
|
|
* Fetch the DM partner's public key (fire-and-forget, or awaitable via promise).
|
|
* Always re-fetches to detect key changes across devices.
|
|
*/
|
|
async _fetchDMPartnerKey(channel) {
|
|
if (!this.e2e || !this.e2e.ready) return;
|
|
if (!channel || !channel.is_dm || !channel.dm_target) return;
|
|
const partnerId = channel.dm_target.id;
|
|
const jwk = await this.e2e.requestPartnerKey(this.socket, partnerId);
|
|
if (jwk) this._dmPublicKeys[partnerId] = jwk;
|
|
},
|
|
|
|
/**
|
|
* Show E2E verification code modal for the current DM.
|
|
*/
|
|
async _showE2EVerification() {
|
|
const partner = this._getE2EPartner();
|
|
if (!partner || !this.e2e?.ready) {
|
|
this._showToast('No partner key available — the other user may not have E2E set up yet', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const code = await this.e2e.getVerificationCode(this.e2e.publicKeyJwk, partner.publicKeyJwk);
|
|
const ch = this.channels.find(c => c.code === this.currentChannel);
|
|
const partnerName = ch?.dm_target?.username || 'Partner';
|
|
|
|
let overlay = document.getElementById('e2e-verify-overlay');
|
|
if (!overlay) {
|
|
overlay = document.createElement('div');
|
|
overlay.id = 'e2e-verify-overlay';
|
|
overlay.className = 'modal-overlay';
|
|
document.body.appendChild(overlay);
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) overlay.style.display = 'none';
|
|
});
|
|
}
|
|
overlay.innerHTML = `
|
|
<div class="modal" style="max-width:420px;text-align:center">
|
|
<h3 style="margin-bottom:8px">🔐 ${t('header.verify_encryption')}</h3>
|
|
<p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
|
|
${t('modals.e2e_verify.desc', { name: this._escapeHtml(partnerName) })}
|
|
</p>
|
|
<div class="e2e-safety-number" style="font-family:monospace;font-size:18px;letter-spacing:2px;line-height:2;padding:16px;background:var(--bg-secondary);border-radius:var(--radius-md);border:1px solid var(--border);user-select:all;word-break:break-all">${code}</div>
|
|
<div style="margin-top:16px;display:flex;gap:8px;justify-content:center">
|
|
<button class="btn-sm btn-accent" id="e2e-copy-code-btn">${t('modals.e2e_verify.copy_btn')}</button>
|
|
<button class="btn-sm" id="e2e-close-verify-btn">${t('modals.common.close')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
overlay.querySelector('#e2e-copy-code-btn').addEventListener('click', () => {
|
|
const markCopied = () => { overlay.querySelector('#e2e-copy-code-btn').textContent = 'Copied!'; };
|
|
navigator.clipboard.writeText(code).then(markCopied).catch(() => {
|
|
try {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = code;
|
|
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
|
|
document.body.appendChild(ta);
|
|
ta.focus(); ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
markCopied();
|
|
} catch { /* could not copy */ }
|
|
});
|
|
});
|
|
overlay.querySelector('#e2e-close-verify-btn').addEventListener('click', () => {
|
|
overlay.style.display = 'none';
|
|
});
|
|
overlay.style.display = 'flex';
|
|
} catch (err) {
|
|
this._showToast('Could not generate verification code', 'error');
|
|
console.error('[E2E] Verification error:', err);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Show a scary confirmation popup before resetting E2E encryption keys.
|
|
*/
|
|
_showE2EResetConfirmation() {
|
|
// _requireE2E ensures E2E is ready before calling this
|
|
|
|
let overlay = document.getElementById('e2e-reset-overlay');
|
|
if (!overlay) {
|
|
overlay = document.createElement('div');
|
|
overlay.id = 'e2e-reset-overlay';
|
|
overlay.className = 'modal-overlay';
|
|
document.body.appendChild(overlay);
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) overlay.style.display = 'none';
|
|
});
|
|
}
|
|
overlay.innerHTML = `
|
|
<div class="modal e2e-reset-modal">
|
|
<h3>⚠️ ${t('header.reset_encryption')}</h3>
|
|
<div class="e2e-reset-warning">
|
|
${t('modals.e2e_reset.warning_irreversible')}
|
|
<ul>
|
|
<li>${t('modals.e2e_reset.li_new_keys')}</li>
|
|
<li>${t('modals.e2e_reset.li_unreadable')}</li>
|
|
<li>${t('modals.e2e_reset.li_reverify')}</li>
|
|
</ul>
|
|
<br>
|
|
${t('modals.e2e_reset.warning_permanent')}
|
|
</div>
|
|
<div class="e2e-confirm-type">
|
|
<p style="font-size:13px;color:var(--text-muted);margin-bottom:8px">${t('modals.e2e_reset.type_confirm')}</p>
|
|
<input type="text" id="e2e-reset-confirm-input" placeholder="${t('modals.e2e_reset.confirm_placeholder')}" autocomplete="off" spellcheck="false">
|
|
</div>
|
|
<div class="e2e-reset-actions">
|
|
<button class="btn-danger" id="e2e-reset-confirm-btn">${t('modals.e2e_reset.confirm_btn')}</button>
|
|
<button class="btn-sm" id="e2e-reset-cancel-btn">${t('modals.common.cancel')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const confirmInput = overlay.querySelector('#e2e-reset-confirm-input');
|
|
const confirmBtn = overlay.querySelector('#e2e-reset-confirm-btn');
|
|
|
|
confirmInput.addEventListener('input', () => {
|
|
if (confirmInput.value.trim().toUpperCase() === 'RESET') {
|
|
confirmBtn.classList.add('enabled');
|
|
} else {
|
|
confirmBtn.classList.remove('enabled');
|
|
}
|
|
});
|
|
|
|
confirmBtn.addEventListener('click', async () => {
|
|
if (confirmInput.value.trim().toUpperCase() !== 'RESET') return;
|
|
overlay.style.display = 'none';
|
|
await this._performE2EKeyReset();
|
|
});
|
|
|
|
overlay.querySelector('#e2e-reset-cancel-btn').addEventListener('click', () => {
|
|
overlay.style.display = 'none';
|
|
});
|
|
|
|
overlay.style.display = 'flex';
|
|
setTimeout(() => confirmInput.focus(), 50);
|
|
},
|
|
|
|
/**
|
|
* Actually reset E2E keys, re-publish, and post a notice in chat.
|
|
* This must work even when E2E can't initialize (e.g. server backup
|
|
* encrypted with old password). Reset generates fresh keys from scratch.
|
|
*/
|
|
async _performE2EKeyReset() {
|
|
// We need the wrapping key from memory, sessionStorage, or password prompt.
|
|
let wrappingKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
|
|
if (!wrappingKey) {
|
|
// Wrapping key was cleared after init — prompt for password directly,
|
|
// then retry the reset (no need to show RESET confirmation again).
|
|
// Use a custom pending action that bypasses _requireE2E.
|
|
this._e2ePwPendingAction = null; // clear normal pending action
|
|
this._e2eResetPending = true;
|
|
this._showE2EPasswordModal();
|
|
return;
|
|
}
|
|
|
|
// Ensure we have an E2E instance (may be null if init failed earlier)
|
|
if (!this.e2e) {
|
|
if (typeof HavenE2E !== 'undefined') {
|
|
this.e2e = new HavenE2E();
|
|
await this.e2e._openDB();
|
|
} else {
|
|
this._showToast('E2E module not available', 'error');
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const ok = await this.e2e.resetKeys(this.socket, wrappingKey);
|
|
if (!ok) {
|
|
this._showToast('Key reset failed', 'error');
|
|
return;
|
|
}
|
|
// Re-publish the new public key (force overwrite)
|
|
await this.e2e.publishKey(this.socket, true);
|
|
// Clear all cached partner shared keys
|
|
this._dmPublicKeys = {};
|
|
|
|
// Post a timestamped notice in the current chat
|
|
this._appendE2ENotice(`🔄 Encryption keys were reset — ${new Date().toLocaleString()}. Previous encrypted messages in this conversation can no longer be decrypted.`);
|
|
|
|
this._showToast('Encryption keys reset successfully', 'success');
|
|
console.log('[E2E] Keys reset by user');
|
|
} catch (err) {
|
|
console.error('[E2E] Key reset error:', err);
|
|
this._showToast('Key reset failed: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Append a styled E2E system notice to the chat.
|
|
*/
|
|
_appendE2ENotice(text) {
|
|
const container = document.getElementById('messages');
|
|
const wasAtBottom = this._coupledToBottom;
|
|
const el = document.createElement('div');
|
|
el.className = 'system-message e2e-notice';
|
|
el.textContent = text;
|
|
container.appendChild(el);
|
|
if (wasAtBottom) this._scrollToBottom(true);
|
|
},
|
|
|
|
/**
|
|
* Decrypt E2E-encrypted messages in place.
|
|
* Both sides derive the same ECDH shared secret.
|
|
*/
|
|
async _decryptMessages(messages, channelCode = null) {
|
|
if (!this.e2e || !this.e2e.ready || !messages || !messages.length) return;
|
|
const ch = this.channels.find(c => c.code === (channelCode || this.currentChannel));
|
|
if (!ch || !ch.is_dm || !ch.dm_target) return;
|
|
|
|
const partnerId = ch.dm_target.id;
|
|
const partnerJwk = this._dmPublicKeys[partnerId];
|
|
|
|
for (const msg of messages) {
|
|
if (HavenE2E.isEncrypted(msg.content)) {
|
|
if (!partnerJwk) {
|
|
msg.content = '[Encrypted — waiting for key...]';
|
|
msg._e2e = true;
|
|
continue;
|
|
}
|
|
const plain = await this.e2e.decrypt(msg.content, partnerId, partnerJwk);
|
|
if (plain !== null) {
|
|
msg.content = plain;
|
|
msg._e2e = true;
|
|
} else {
|
|
msg.content = '[Encrypted — unable to decrypt]';
|
|
msg._e2e = true;
|
|
}
|
|
}
|
|
// Also decrypt the reply preview text if the replied-to message was encrypted
|
|
if (msg.replyContext && msg.replyContext.content && HavenE2E.isEncrypted(msg.replyContext.content)) {
|
|
if (!partnerJwk) {
|
|
msg.replyContext.content = '[Encrypted — waiting for key...]';
|
|
} else {
|
|
const rplain = await this.e2e.decrypt(msg.replyContext.content, partnerId, partnerJwk);
|
|
msg.replyContext.content = rplain !== null ? rplain : '[Encrypted — unable to decrypt]';
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Find all e2e-img-pending images in a DOM element (or the messages container),
|
|
* fetch their encrypted data, decrypt, and display as blob URLs.
|
|
*/
|
|
_decryptE2EImages(root) {
|
|
if (!root) root = document.getElementById('messages');
|
|
if (!root) return;
|
|
const imgs = root.querySelectorAll('img.e2e-img-pending');
|
|
if (!imgs.length) return;
|
|
|
|
const partner = this._getE2EPartner();
|
|
if (!partner) return;
|
|
|
|
imgs.forEach(img => {
|
|
img.classList.remove('e2e-img-pending');
|
|
img.classList.add('e2e-img-loading');
|
|
const url = img.dataset.e2eSrc;
|
|
const mime = img.dataset.e2eMime || 'image/png';
|
|
|
|
// Only fetch local upload paths to prevent SSRF
|
|
if (!url || !url.startsWith('/uploads/')) {
|
|
img.alt = '[Invalid encrypted image URL]';
|
|
img.classList.remove('e2e-img-loading');
|
|
img.classList.add('e2e-img-failed');
|
|
return;
|
|
}
|
|
|
|
fetch(url)
|
|
.then(r => { if (!r.ok) throw new Error(r.status); return r.arrayBuffer(); })
|
|
.then(buf => this.e2e.decryptBytes(new Uint8Array(buf), partner.userId, partner.publicKeyJwk))
|
|
.then(plain => {
|
|
const blob = new Blob([plain], { type: mime });
|
|
img.src = URL.createObjectURL(blob);
|
|
img.classList.remove('e2e-img-loading');
|
|
})
|
|
.catch(() => {
|
|
img.alt = '[Encrypted image — unable to decrypt]';
|
|
img.classList.remove('e2e-img-loading');
|
|
img.classList.add('e2e-img-failed');
|
|
});
|
|
});
|
|
},
|
|
|
|
};
|