diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ec11e4..9b89b84f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,64 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Haven uses [Sema --- +## [1.2.0] — 2026-02-12 + +### Added — Voice UX +- **Join / leave audio cues** — synthesized tones play when users enter or leave voice chat. +- **Talking indicators** — usernames glow green while speaking, with 300 ms hysteresis for smooth animation. +- **Multi-stream screen sharing** — multiple users can share screens simultaneously in a CSS Grid tiled layout with per-user video tiles, labels, and close buttons. + +### Added — Message Pinning +- **Pin / unpin messages** (admin-only) — pin button in message hover toolbar. +- **Pinned messages panel** — sidebar panel listing all pinned messages in a channel with jump-to-message. +- **50-pin cap per channel** to prevent abuse. +- **Database-backed** — new `pinned_messages` table with foreign keys; pins survive restarts. + +### Added — Enhanced Markdown +- **Fenced code blocks** — triple-backtick blocks with optional language labels render with styled monospace containers. +- **Blockquotes** — lines starting with `>` render with left-border accent styling. + +### Added — Link Previews +- **Automatic OpenGraph previews** — shared URLs fetch title, description, and thumbnail server-side. +- **30-minute cache** — previews are cached to avoid repeated fetches. +- **SSRF protection** — private/internal IPs are blocked from the preview fetcher. + +### Added — GIF Search +- **Tenor-powered GIF picker** — search and send GIFs inline from the message input. +- **Admin-configurable API key** — Tenor API key can be set from the admin GIF picker UI with an inline setup guide. +- **Server-stored key** — API key saved in `server_settings` DB table (never exposed to non-admins). + +### Fixed — Security +- **Admin username hijack via rename** — non-admin users can no longer claim the admin username through `/nick` or rename. +- **XSS via attribute injection** — `_escapeHtml` now escapes `"` and `'` characters, preventing injection through OG metadata or user content. +- **SSRF in link previews** — `/api/link-preview` now blocks requests to localhost, private ranges (10.x, 192.168.x, 172.16-31.x), link-local (169.254.169.254), and internal domains. +- **API key leak** — `get-server-settings` no longer sends sensitive keys (e.g. `tenor_api_key`) to non-admin users. +- **Cross-channel reaction removal** — `remove-reaction` now verifies the message belongs to the current channel. +- **Voice signaling without membership** — `voice-offer`, `voice-answer`, and `voice-ice-candidate` now verify the sender is in the voice room. +- **Typing indicator channel check** — typing events now verify the user is in the claimed channel. + +### Fixed — Bugs +- **Voice audio broken** — eliminated duplicate `MediaStreamSource` creation; single source now splits to analyser and gain node. +- **Spotty talking indicator** — added 300 ms sustain hysteresis to prevent flicker during natural speech pauses. +- **Screen share invisible** — added SDP rollback for renegotiation glare, `event.streams[0]` for proper stream association, `track.onunmute`, and explicit `play()` on muted video tiles. +- **GIF send completely broken** — fixed wrong property names (`channelCode` → `code`, `this.replyTo` → `this.replyingTo`) that silently dropped every GIF message. +- **Reconnect dead channel** — socket reconnect now re-emits `enter-channel`, `get-messages`, `get-channel-members`, and other state-restoring events. +- **Screen share privacy leak** — closing the screen share viewer now actually stops the broadcast (calls `stopScreenShare()`) instead of just hiding the UI. +- **Auto-scroll failure** — `_scrollToBottom` after appending messages now uses the force flag to prevent large messages from blocking scroll. +- **Delete-user FK violation** — user deletion now cleans up `pinned_messages`, `high_scores`, `eula_acceptances`, and `user_preferences` to prevent foreign key errors. +- **Delete-channel incomplete** — channel deletion now explicitly removes associated pinned messages. +- **Delete-message incomplete** — message deletion now removes associated pinned message entries. +- **LIKE wildcard injection** — search-messages now escapes `%`, `_`, and `\` in search queries. + +### Changed — Performance +- **N+1 query eliminated** — `get-messages` replaced 240 individual queries (for 80 messages) with 3 batch queries using `WHERE ... IN (...)` for reply context, reactions, and pin status. + +### Changed +- `edit-message`, `delete-message`, `pin-message`, `unpin-message` DB operations wrapped in try/catch for graceful error handling. +- Version bumped to 1.2.0. + +--- + ## [1.1.0] — 2026-02-11 ### 🔒 Data Isolation diff --git a/README.md b/README.md index f65de3e3..0bfe861e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > **Your server. Your rules. No cloud. No accounts with Big Tech. No one reading your messages.** -![Version](https://img.shields.io/badge/version-1.0.0-blue) +![Version](https://img.shields.io/badge/version-1.2.0-blue) ![License](https://img.shields.io/badge/license-MIT-green) ![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen) ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey) @@ -30,9 +30,12 @@ You launch it, your friends connect to your IP address, and you have a private g | Category | What You Get | |----------|-------------| -| **Chat** | Real-time messaging, image uploads (paste/drag/drop), typing indicators, message editing, replies, emoji reactions, @mentions with autocomplete | -| **Voice** | Peer-to-peer audio chat, per-user volume sliders, mute/deafen | -| **Formatting** | **Bold**, *italic*, ~~strikethrough~~, `code`, \|\|spoilers\|\|, auto-linked URLs | +| **Chat** | Real-time messaging, image uploads (paste/drag/drop), typing indicators, message editing, replies, emoji reactions, @mentions with autocomplete, message pinning (admin) | +| **Voice** | Peer-to-peer audio chat, per-user volume sliders, mute/deafen, join/leave audio cues, talking indicators | +| **Screen Share** | Multi-stream screen sharing with tiled grid layout, per-user video tiles, one-click close | +| **Formatting** | **Bold**, *italic*, ~~strikethrough~~, `code`, \|\|spoilers\|\|, auto-linked URLs, fenced code blocks with language labels, blockquotes | +| **Link Previews** | Automatic OG metadata previews for shared URLs with title, description, and thumbnail | +| **GIF Search** | Tenor-powered GIF picker — search and send GIFs inline (admin-configurable API key) | | **Slash Commands** | `/shrug`, `/tableflip`, `/roll 2d20`, `/flip`, `/me`, `/spoiler`, `/tts`, and more — type `/` to see them all | | **Search** | Search messages in any channel with Ctrl+F | | **Themes** | 12 built-in themes: Haven, Discord, Matrix, Tron, HALO, LoTR, Cyberpunk, Nord, Dracula, Bloodborne, Ice, Abyss | @@ -203,6 +206,10 @@ Your theme choice persists across sessions. Voice is peer-to-peer — audio goes directly between users, not through the server. Requires HTTPS. +- **Join / leave cues** — synthesized audio tones when users enter or leave voice. +- **Talking indicators** — usernames glow green when speaking (300 ms hysteresis for smooth animation). +- **Screen sharing** — click **🖥️ Share Screen** to broadcast your display. Multiple users can share simultaneously in a tiled grid. + --- ## Admin Guide diff --git a/package.json b/package.json index 6de157d9..8694abc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "haven", - "version": "1.1.0", + "version": "1.2.0", "description": "Self-hosted private chat — your server, your rules", "license": "MIT", "main": "server.js", diff --git a/public/js/app.js b/public/js/app.js index db19e00f..61e68873 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -103,6 +103,14 @@ class HavenApp { this._setLed('status-server-led', 'on'); document.getElementById('status-server-text').textContent = 'Connected'; this._startPingMonitor(); + // Re-join channel after reconnect (server lost our room membership) + this.socket.emit('get-channels'); + this.socket.emit('get-server-settings'); + if (this.currentChannel) { + this.socket.emit('enter-channel', { code: this.currentChannel }); + this.socket.emit('get-messages', { code: this.currentChannel }); + this.socket.emit('get-channel-members', { code: this.currentChannel }); + } }); this.socket.on('disconnect', () => { @@ -1394,7 +1402,7 @@ class HavenApp { // Fetch link previews for this message this._fetchLinkPreviews(msgEl); if (wasAtBottom) { - this._scrollToBottom(); + this._scrollToBottom(true); // Also scroll after images load (they shift content down) const imgs = container.lastElementChild?.querySelectorAll('img'); if (imgs) imgs.forEach(img => { @@ -1901,6 +1909,12 @@ class HavenApp { _hideScreenShare() { const container = document.getElementById('screen-share-container'); const grid = document.getElementById('screen-share-grid'); + // Stop our own screen share if active (don't just hide the UI!) + if (this.voice && this.voice.isScreenSharing) { + this.voice.stopScreenShare(); + document.getElementById('screen-share-btn').textContent = '🖥️ Share'; + document.getElementById('screen-share-btn').classList.remove('sharing'); + } // Stop all video elements and clear grid grid.querySelectorAll('video').forEach(v => { v.srcObject = null; }); grid.innerHTML = ''; @@ -1912,7 +1926,7 @@ class HavenApp { _escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; - return div.innerHTML; + return div.innerHTML.replace(/"/g, '"').replace(/'/g, '''); } _isImageUrl(str) { @@ -2241,11 +2255,11 @@ class HavenApp { _sendGifMessage(url) { if (!this.currentChannel || !url) return; const payload = { - channelCode: this.currentChannel, + code: this.currentChannel, content: url, }; - if (this.replyTo) { - payload.replyTo = this.replyTo; + if (this.replyingTo) { + payload.replyTo = this.replyingTo.id; this._clearReply(); } this.socket.emit('send-message', payload); diff --git a/server.js b/server.js index e73a7bfa..da05bf8c 100644 --- a/server.js +++ b/server.js @@ -243,6 +243,16 @@ app.get('/api/link-preview', async (req, res) => { if (!['http:', 'https:'].includes(parsed.protocol)) { return res.status(400).json({ error: 'Only http/https URLs allowed' }); } + // Block private/internal IPs (SSRF protection) + const host = parsed.hostname.toLowerCase(); + if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || + host === '::1' || host === '[::1]' || + host.startsWith('10.') || host.startsWith('192.168.') || + /^172\.(1[6-9]|2\d|3[01])\./.test(host) || + host === '169.254.169.254' || + host.endsWith('.local') || host.endsWith('.internal')) { + return res.status(400).json({ error: 'Private addresses not allowed' }); + } } catch { return res.status(400).json({ error: 'Invalid URL' }); } // Cache check diff --git a/src/socketHandlers.js b/src/socketHandlers.js index b8afd789..eda4fea8 100644 --- a/src/socketHandlers.js +++ b/src/socketHandlers.js @@ -281,27 +281,47 @@ function setupSocketHandlers(io, db) { `).all(channel.id, limit); } - // Attach reply context and reactions to each message - const getReplyStmt = db.prepare(` - SELECT m.id, m.content, COALESCE(u.username, '[Deleted User]') as username FROM messages m - LEFT JOIN users u ON m.user_id = u.id WHERE m.id = ? - `); - const getReactionsStmt = db.prepare(` - SELECT r.emoji, r.user_id, u.username FROM reactions r - JOIN users u ON r.user_id = u.id WHERE r.message_id = ? - `); + // Batch-enrich messages (reply context, reactions, pin status) in 3 queries + // instead of N+1 per-message lookups. + const msgIds = messages.map(m => m.id); + const replyIds = [...new Set(messages.filter(m => m.reply_to).map(m => m.reply_to))]; - const isPinnedStmt = db.prepare( - 'SELECT 1 FROM pinned_messages WHERE message_id = ?' - ); + // Batch reply context + const replyMap = new Map(); + if (replyIds.length > 0) { + const ph = replyIds.map(() => '?').join(','); + db.prepare(` + SELECT m.id, m.content, COALESCE(u.username, '[Deleted User]') as username + FROM messages m LEFT JOIN users u ON m.user_id = u.id + WHERE m.id IN (${ph}) + `).all(...replyIds).forEach(r => replyMap.set(r.id, r)); + } + + // Batch reactions + const reactionMap = new Map(); // messageId → [reactions] + if (msgIds.length > 0) { + const ph = msgIds.map(() => '?').join(','); + db.prepare(` + SELECT r.message_id, r.emoji, r.user_id, u.username + FROM reactions r JOIN users u ON r.user_id = u.id + WHERE r.message_id IN (${ph}) + `).all(...msgIds).forEach(r => { + if (!reactionMap.has(r.message_id)) reactionMap.set(r.message_id, []); + reactionMap.get(r.message_id).push({ emoji: r.emoji, user_id: r.user_id, username: r.username }); + }); + + // Batch pin status + var pinnedSet = new Set( + db.prepare(`SELECT message_id FROM pinned_messages WHERE message_id IN (${ph})`) + .all(...msgIds).map(r => r.message_id) + ); + } const enriched = messages.map(m => { const obj = { ...m }; - if (m.reply_to) { - obj.replyContext = getReplyStmt.get(m.reply_to) || null; - } - obj.reactions = getReactionsStmt.all(m.id); - obj.pinned = !!isPinnedStmt.get(m.id); + obj.replyContext = m.reply_to ? (replyMap.get(m.reply_to) || null) : null; + obj.reactions = reactionMap.get(m.id) || []; + obj.pinned = pinnedSet ? pinnedSet.has(m.id) : false; return obj; }); @@ -326,13 +346,15 @@ function setupSocketHandlers(io, db) { ).get(channel.id, socket.user.id); if (!member) return; + // Escape LIKE wildcards so user can't match everything with % or _ + const escapedQuery = query.replace(/[%_\\]/g, '\\$&'); const results = db.prepare(` SELECT m.id, m.content, m.created_at, COALESCE(u.username, '[Deleted User]') as username, u.id as user_id FROM messages m LEFT JOIN users u ON m.user_id = u.id - WHERE m.channel_id = ? AND m.content LIKE ? + WHERE m.channel_id = ? AND m.content LIKE ? ESCAPE '\\' ORDER BY m.created_at DESC LIMIT 25 - `).all(channel.id, `%${query}%`); + `).all(channel.id, `%${escapedQuery}%`); socket.emit('search-results', { results, query }); }); @@ -442,6 +464,8 @@ function setupSocketHandlers(io, db) { socket.on('typing', (data) => { if (!data || typeof data !== 'object') return; if (!isString(data.code, 8, 8)) return; + // Only allow typing in the channel the user is currently in + if (data.code !== socket.currentChannel) return; socket.to(`channel:${data.code}`).emit('user-typing', { channelCode: data.code, username: socket.user.username @@ -501,6 +525,8 @@ function setupSocketHandlers(io, db) { socket.on('voice-offer', (data) => { if (!data || typeof data !== 'object') return; if (!isString(data.code, 8, 8) || !isInt(data.targetUserId) || !data.offer) return; + // Verify sender is in the voice room + if (!voiceUsers.get(data.code)?.has(socket.user.id)) return; const target = voiceUsers.get(data.code)?.get(data.targetUserId); if (target) { io.to(target.socketId).emit('voice-offer', { @@ -514,6 +540,7 @@ function setupSocketHandlers(io, db) { socket.on('voice-answer', (data) => { if (!data || typeof data !== 'object') return; if (!isString(data.code, 8, 8) || !isInt(data.targetUserId) || !data.answer) return; + if (!voiceUsers.get(data.code)?.has(socket.user.id)) return; const target = voiceUsers.get(data.code)?.get(data.targetUserId); if (target) { io.to(target.socketId).emit('voice-answer', { @@ -527,6 +554,7 @@ function setupSocketHandlers(io, db) { socket.on('voice-ice-candidate', (data) => { if (!data || typeof data !== 'object') return; if (!isString(data.code, 8, 8) || !isInt(data.targetUserId)) return; + if (!voiceUsers.get(data.code)?.has(socket.user.id)) return; const target = voiceUsers.get(data.code)?.get(data.targetUserId); if (target) { io.to(target.socketId).emit('voice-ice-candidate', { @@ -623,6 +651,12 @@ function setupSocketHandlers(io, db) { const code = socket.currentChannel; if (!code) return; + // Verify message belongs to this channel + const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(code); + if (!channel) return; + const msgCheck = db.prepare('SELECT id FROM messages WHERE id = ? AND channel_id = ?').get(data.messageId, channel.id); + if (!msgCheck) return; + db.prepare( 'DELETE FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ?' ).run(data.messageId, socket.user.id, data.emoji); @@ -685,6 +719,12 @@ function setupSocketHandlers(io, db) { return socket.emit('error-msg', 'Username already taken'); } + // Block renaming to the admin username (privilege escalation prevention) + const adminName = (process.env.ADMIN_USERNAME || 'admin').toLowerCase(); + if (newName.toLowerCase() === adminName && !socket.user.isAdmin) { + return socket.emit('error-msg', 'That username is reserved'); + } + try { db.prepare('UPDATE users SET username = ? WHERE id = ?').run(newName, socket.user.id); } catch (err) { @@ -692,12 +732,6 @@ function setupSocketHandlers(io, db) { return socket.emit('error-msg', 'Failed to update username'); } - // Block renaming to the admin username (privilege escalation prevention) - const adminName = (process.env.ADMIN_USERNAME || 'admin').toLowerCase(); - if (newName.toLowerCase() === adminName && socket.user.id !== db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(adminName)?.id) { - return socket.emit('error-msg', 'That username is reserved'); - } - const oldName = socket.user.username; socket.user.username = newName; @@ -756,8 +790,9 @@ function setupSocketHandlers(io, db) { if (!channel) return; const deleteAll = db.transaction((chId) => { - // Delete reactions first (they reference messages) + // Delete child records first (reactions, pins reference messages) db.prepare('DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)').run(chId); + db.prepare('DELETE FROM pinned_messages WHERE channel_id = ?').run(chId); db.prepare('DELETE FROM messages WHERE channel_id = ?').run(chId); db.prepare('DELETE FROM channel_members WHERE channel_id = ?').run(chId); db.prepare('DELETE FROM channels WHERE id = ?').run(chId); @@ -795,9 +830,14 @@ function setupSocketHandlers(io, db) { const newContent = data.content.trim(); if (!newContent) return; - db.prepare( - 'UPDATE messages SET content = ?, edited_at = datetime(\'now\') WHERE id = ?' - ).run(newContent, data.messageId); + try { + db.prepare( + 'UPDATE messages SET content = ?, edited_at = datetime(\'now\') WHERE id = ?' + ).run(newContent, data.messageId); + } catch (err) { + console.error('Edit message error:', err); + return socket.emit('error-msg', 'Failed to edit message'); + } io.to(`channel:${code}`).emit('message-edited', { channelCode: code, @@ -829,8 +869,14 @@ function setupSocketHandlers(io, db) { return socket.emit('error-msg', 'You can only delete your own messages'); } - db.prepare('DELETE FROM reactions WHERE message_id = ?').run(data.messageId); - db.prepare('DELETE FROM messages WHERE id = ?').run(data.messageId); + try { + db.prepare('DELETE FROM pinned_messages WHERE message_id = ?').run(data.messageId); + db.prepare('DELETE FROM reactions WHERE message_id = ?').run(data.messageId); + db.prepare('DELETE FROM messages WHERE id = ?').run(data.messageId); + } catch (err) { + console.error('Delete message error:', err); + return socket.emit('error-msg', 'Failed to delete message'); + } io.to(`channel:${code}`).emit('message-deleted', { channelCode: code, @@ -872,9 +918,14 @@ function setupSocketHandlers(io, db) { return socket.emit('error-msg', 'Channel has reached the 50-pin limit'); } - db.prepare( - 'INSERT INTO pinned_messages (message_id, channel_id, pinned_by) VALUES (?, ?, ?)' - ).run(data.messageId, channel.id, socket.user.id); + try { + db.prepare( + 'INSERT INTO pinned_messages (message_id, channel_id, pinned_by) VALUES (?, ?, ?)' + ).run(data.messageId, channel.id, socket.user.id); + } catch (err) { + console.error('Pin message error:', err); + return socket.emit('error-msg', 'Failed to pin message'); + } io.to(`channel:${code}`).emit('message-pinned', { channelCode: code, @@ -901,7 +952,12 @@ function setupSocketHandlers(io, db) { ).get(data.messageId, channel.id); if (!pin) return socket.emit('error-msg', 'Message is not pinned'); - db.prepare('DELETE FROM pinned_messages WHERE message_id = ?').run(data.messageId); + try { + db.prepare('DELETE FROM pinned_messages WHERE message_id = ?').run(data.messageId); + } catch (err) { + console.error('Unpin message error:', err); + return socket.emit('error-msg', 'Failed to unpin message'); + } io.to(`channel:${code}`).emit('message-unpinned', { channelCode: code, @@ -1095,6 +1151,11 @@ function setupSocketHandlers(io, db) { db.prepare('DELETE FROM mutes WHERE user_id = ?').run(uid); db.prepare('DELETE FROM bans WHERE user_id = ?').run(uid); db.prepare('DELETE FROM channel_members WHERE user_id = ?').run(uid); + // Re-assign pins to the admin performing the deletion, then nullify messages + db.prepare('UPDATE pinned_messages SET pinned_by = ? WHERE pinned_by = ?').run(socket.user.id, uid); + db.prepare('DELETE FROM high_scores WHERE user_id = ?').run(uid); + db.prepare('DELETE FROM eula_acceptances WHERE user_id = ?').run(uid); + db.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(uid); // Mark their messages as [deleted user] instead of deleting (preserves chat history) db.prepare('UPDATE messages SET user_id = NULL WHERE user_id = ?').run(uid); db.prepare('DELETE FROM users WHERE id = ?').run(uid); @@ -1187,7 +1248,11 @@ function setupSocketHandlers(io, db) { socket.on('get-server-settings', () => { const rows = db.prepare('SELECT key, value FROM server_settings').all(); const settings = {}; - rows.forEach(r => { settings[r.key] = r.value; }); + const sensitiveKeys = ['tenor_api_key']; + rows.forEach(r => { + if (sensitiveKeys.includes(r.key) && !socket.user.isAdmin) return; + settings[r.key] = r.value; + }); socket.emit('server-settings', settings); });