v1.2.0: comprehensive bug hunt + voice UX + pinning + link previews + GIF search

Security fixes:
- Admin username hijack via rename (critical)
- XSS via unescaped quotes in _escapeHtml (critical)
- SSRF protection on /api/link-preview
- API key leak in get-server-settings to non-admins
- Cross-channel reaction removal without verification
- Voice signaling (offer/answer/ICE) without room membership check
- Typing indicator without channel membership check

Bug fixes:
- Voice audio: single MediaStreamSource splits to analyser + gain
- Talking indicator: 300ms hysteresis for smooth detection
- Screen share: SDP rollback, event.streams[0], track.onunmute
- GIF send broken: wrong property names silently dropped all GIFs
- Reconnect recovery: re-emit channel/state events on socket reconnect
- Screen share privacy: stopScreenShare() on viewer close
- Auto-scroll: force flag after message append
- Delete-user FK violation: clean up all related tables
- Delete-channel: remove associated pinned_messages
- Delete-message: remove associated pinned_messages
- LIKE wildcard injection in search-messages

Performance:
- N+1 query in get-messages replaced with 3 batch queries

Features (from prior uncommitted work):
- Voice join/leave audio cues, talking indicators
- Multi-stream screen sharing with tiled grid
- Message pinning (admin-only, 50/channel cap)
- Enhanced markdown: fenced code blocks, blockquotes
- Link previews with OG metadata + 30min cache
- Tenor GIF search with admin-configurable API key

Docs:
- README updated with new features, voice/screen share docs
- CHANGELOG v1.2.0 entry with full details
- Version bumped to 1.2.0
This commit is contained in:
ancsemi committed 2026-02-11 20:33:29 -05:00
1 parent 330e8f6f25
commit 415ff761e4
6 files changed
+200 -46

No files matched your search

+58
View File
@@ -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
+11 -4
View File
@@ -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
+1 -1
View File
@@ -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",
+19 -5
View File
@@ -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);
+10
View File
@@ -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
+101 -36
View File
@@ -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);
});