mirror of
https://github.com/ancsemi/Haven
synced 2026-04-21 13:37:41 +00:00
v2.9.7 — Remove Google STUN, add STUN_URLS env var
- Replace Google STUN servers with open-source defaults (stunprotocol.org, nextcloud.com) - Add STUN_URLS env var for custom STUN server configuration - Update .env.example with STUN_URLS documentation
This commit is contained in:
parent
4b9e16d7b6
commit
6e756b6939
18 changed files with 188 additions and 45 deletions
|
|
@ -26,6 +26,12 @@ ADMIN_USERNAME=admin
|
|||
# Optional: Override the data directory location
|
||||
# HAVEN_DATA_DIR=
|
||||
|
||||
# Optional: Override STUN servers for voice/WebRTC.
|
||||
# Default: stun.stunprotocol.org + stun.nextcloud.com
|
||||
# Comma-separated list of STUN URIs. Set this to point at your own coturn
|
||||
# or any STUN server for fully self-hosted voice with no external dependencies.
|
||||
# STUN_URLS=stun:your-server.com:3478,stun:backup-server.com:3478
|
||||
|
||||
# Optional: TURN server for voice/screen sharing across the internet.
|
||||
# Without TURN, voice only works on the same network (LAN).
|
||||
# Recommended: run coturn on the same box or a cheap VPS.
|
||||
|
|
|
|||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -11,6 +11,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Haven uses [Sema
|
|||
|
||||
---
|
||||
|
||||
## [2.9.7] — 2026-04-09
|
||||
|
||||
### Changed
|
||||
- **Removed Google STUN dependency** — voice/WebRTC now defaults to open-source public STUN servers (`stun.stunprotocol.org` and `stun.nextcloud.com`) instead of Google's. No functional change for end users, just removes the Google dependency for a project built around self-hosting.
|
||||
|
||||
### Added
|
||||
- **`STUN_URLS` environment variable** — server admins can now override the default STUN servers with their own (e.g., a self-hosted coturn instance) for fully self-contained voice with zero external dependencies. Comma-separated list of STUN URIs.
|
||||
|
||||
---
|
||||
|
||||
## [2.9.6] — 2026-04-07
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1415,12 +1415,12 @@
|
|||
</div>
|
||||
|
||||
<div class="download-card fade-in">
|
||||
<h2>⬡ Haven Server — v2.9.6</h2>
|
||||
<h2>⬡ Haven Server — v2.9.7</h2>
|
||||
<p class="download-version">Latest stable release · Windows, macOS & Linux · ~5 MB</p>
|
||||
|
||||
<div class="download-btn-group">
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.6.zip" class="btn btn-primary download-main">
|
||||
<span class="icon">⬇</span> Download v2.9.6 (.zip)
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip" class="btn btn-primary download-main">
|
||||
<span class="icon">⬇</span> Download v2.9.7 (.zip)
|
||||
</a>
|
||||
<div class="download-alt-links">
|
||||
<a href="https://github.com/ancsemi/Haven" target="_blank">⛭ View on GitHub</a>
|
||||
|
|
@ -1437,7 +1437,11 @@
|
|||
<div class="version-list">
|
||||
<div class="version-list-inner">
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.9.6</span><span class="v-tag latest">Latest</span></div>
|
||||
<div><span class="v-name">v2.9.7</span><span class="v-tag latest">Latest</span></div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.9.6</span> — Custom Terms of Service, unpin fix</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.6.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "haven",
|
||||
"version": "2.9.6",
|
||||
"version": "2.9.7",
|
||||
"description": "Haven — self-hosted private chat for your server, your rules",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "server.js",
|
||||
|
|
|
|||
|
|
@ -1033,6 +1033,14 @@
|
|||
<p class="settings-hint" data-i18n="settings.desktop_app_section.minimize_to_tray_hint">Closing the window hides Haven to the system tray instead of quitting.</p>
|
||||
</div>
|
||||
|
||||
<div class="desktop-pref-row">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="pref-hide-menu-bar">
|
||||
<span class="toggle-label" data-i18n="settings.desktop_app_section.hide_menu_bar">Hide Menu Bar</span>
|
||||
</label>
|
||||
<p class="settings-hint" data-i18n="settings.desktop_app_section.hide_menu_bar_hint">Hide the File / Edit / View / Window / Help toolbar. Press Alt to temporarily show it.</p>
|
||||
</div>
|
||||
|
||||
<div class="desktop-pref-row">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="pref-force-sdr">
|
||||
|
|
|
|||
|
|
@ -12922,7 +12922,7 @@ ul.chat-list { list-style-type: disc; }
|
|||
}
|
||||
.rac-perm-item.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Current role display in config pane */
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const ALL_PERMS = [
|
|||
'pin_message', 'archive_messages', 'kick_user', 'mute_user', 'ban_user',
|
||||
'rename_channel', 'rename_sub_channel', 'set_channel_topic', 'manage_sub_channels',
|
||||
'create_channel', 'create_temp_channel', 'upload_files', 'use_voice', 'use_tts', 'manage_webhooks', 'mention_everyone', 'view_history',
|
||||
'view_all_members', 'manage_emojis', 'manage_soundboard', 'manage_music_queue', 'promote_user', 'transfer_admin',
|
||||
'view_all_members', 'view_channel_members', 'manage_emojis', 'manage_soundboard', 'manage_music_queue', 'promote_user', 'transfer_admin',
|
||||
'manage_roles', 'manage_server', 'delete_channel'
|
||||
];
|
||||
//Similarly flavored solution to perm labels
|
||||
|
|
@ -31,6 +31,7 @@ const PERM_LABELS = {
|
|||
get mention_everyone() { return t('permissions.mention_everyone'); },
|
||||
get view_history() { return t('permissions.view_history'); },
|
||||
get view_all_members() { return t('permissions.view_all_members'); },
|
||||
get view_channel_members() { return t('permissions.view_channel_members'); },
|
||||
get manage_emojis() { return t('permissions.manage_emojis'); },
|
||||
get manage_soundboard() { return t('permissions.manage_soundboard'); },
|
||||
get manage_music_queue() { return t('permissions.manage_music_queue'); },
|
||||
|
|
@ -797,7 +798,9 @@ _openAllMembersModal() {
|
|||
document.getElementById('all-members-count').textContent = '';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
this.socket.emit('get-all-members', {}, (res) => {
|
||||
// Pass current channel so the server can fall back to view_channel_members
|
||||
const payload = this.currentChannel ? { channelCode: this.currentChannel } : {};
|
||||
this.socket.emit('get-all-members', payload, (res) => {
|
||||
if (res.error) {
|
||||
list.innerHTML = `<p class="muted-text" style="text-align:center;padding:20px">${this._escapeHtml(res.error)}</p>`;
|
||||
return;
|
||||
|
|
@ -805,6 +808,11 @@ _openAllMembersModal() {
|
|||
this._allMembersData = res.members || [];
|
||||
this._allMembersChannels = res.allChannels || [];
|
||||
this._allMembersPerms = res.callerPerms || {};
|
||||
// Update title to reflect channel-only vs all members
|
||||
const titleEl = document.querySelector('#all-members-modal [data-i18n="modals.all_members.title"]');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = res.channelOnly ? t('modals.all_members.channel_title') : t('modals.all_members.title');
|
||||
}
|
||||
document.getElementById('all-members-count').textContent = `(${res.total})`;
|
||||
this._renderAllMembers(this._allMembersData);
|
||||
});
|
||||
|
|
@ -3287,7 +3295,10 @@ _renderRacConfig() {
|
|||
const callerHasPerm = callerIsAdmin || callerPerms.includes('*') || callerPerms.includes(p);
|
||||
const isAdminOnly = adminOnlyPerms.includes(p) && !callerIsAdmin;
|
||||
const isReadOnly = !selectedRoleId || isAdminOnly || !callerHasPerm;
|
||||
return `<label class="rac-perm-item${isReadOnly ? ' disabled' : ''}${checked ? ' checked' : ''}">
|
||||
const tooltip = isReadOnly && selectedRoleId
|
||||
? (isAdminOnly ? 'Owner only' : 'You don\'t have this permission')
|
||||
: '';
|
||||
return `<label class="rac-perm-item${isReadOnly ? ' disabled' : ''}${checked ? ' checked' : ''}"${tooltip ? ` title="${tooltip}"` : ''}>
|
||||
<input type="checkbox" data-perm="${p}" ${checked ? 'checked' : ''} ${isReadOnly ? 'disabled' : ''}>
|
||||
${permLabels[p] || p}
|
||||
</label>`;
|
||||
|
|
|
|||
|
|
@ -238,6 +238,13 @@ _openChannelCtxMenu(code, btnEl) {
|
|||
if (deleteBtn && !canManageChannels && this._hasPerm('delete_channel')) {
|
||||
deleteBtn.style.display = '';
|
||||
}
|
||||
// Also show delete for users who created a temp channel
|
||||
if (deleteBtn && !canManageChannels && !this._hasPerm('delete_channel')) {
|
||||
const ch = this.channels.find(c => c.code === code);
|
||||
if (ch && ch.is_temp_voice && ch.created_by === this.user?.id) {
|
||||
deleteBtn.style.display = '';
|
||||
}
|
||||
}
|
||||
menu.querySelectorAll('.mod-only').forEach(el => {
|
||||
el.style.display = isMod ? '' : 'none';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -869,8 +869,11 @@ _startStatusBar() {
|
|||
// Belt-and-suspenders: ensure the CSS attribute is present (preload
|
||||
// sets this on DOMContentLoaded, but reinforce here in case of timing)
|
||||
document.documentElement.setAttribute('data-desktop-app', '1');
|
||||
// If the Desktop preload already injected its own fixed footer bar,
|
||||
// don't force the original status bar visible (that causes duplicates)
|
||||
const hasDesktopFooter = !!document.getElementById('haven-desktop-footer');
|
||||
const sb = document.getElementById('status-bar');
|
||||
if (sb) {
|
||||
if (sb && !hasDesktopFooter) {
|
||||
sb.style.setProperty('display', 'flex', 'important');
|
||||
// Safety net: after one frame, verify the bar is actually inside the
|
||||
// visible viewport. If Electron's BrowserView clips it (100dvh
|
||||
|
|
|
|||
|
|
@ -1628,6 +1628,10 @@ _setupModalExpand() {
|
|||
|
||||
// Auto-inject an expand/maximize toggle button into every modal's header
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
// Skip promo/centered popups — they're not regular modals
|
||||
if (modal.classList.contains('android-beta-promo') ||
|
||||
modal.classList.contains('desktop-promo')) return;
|
||||
|
||||
// Find the header container — either .settings-header / .activities-header or the first h3
|
||||
let headerContainer = modal.querySelector('.settings-header, .activities-header');
|
||||
let header = modal.querySelector('h3');
|
||||
|
|
@ -1645,20 +1649,43 @@ _setupModalExpand() {
|
|||
btn.title = isMax ? 'Restore size' : 'Expand';
|
||||
});
|
||||
|
||||
if (headerContainer) {
|
||||
// Insert before the close button
|
||||
const closeBtn = headerContainer.querySelector('.settings-close-btn');
|
||||
if (closeBtn) {
|
||||
headerContainer.insertBefore(btn, closeBtn);
|
||||
} else {
|
||||
headerContainer.appendChild(btn);
|
||||
// Create X close button
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'modal-expand-btn';
|
||||
closeBtn.title = 'Close';
|
||||
closeBtn.textContent = '✕';
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const overlay = modal.closest('.modal-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
if (modal.classList.contains('modal-maximized')) {
|
||||
modal.classList.remove('modal-maximized');
|
||||
btn.textContent = '⛶';
|
||||
btn.title = 'Expand / Restore';
|
||||
}
|
||||
});
|
||||
|
||||
if (headerContainer) {
|
||||
// Settings/activities modal: wrap buttons in a group to avoid space-between spreading
|
||||
const existingClose = headerContainer.querySelector('.settings-close-btn');
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.style.cssText = 'display:flex;align-items:center;gap:4px;margin-left:auto;';
|
||||
btnGroup.appendChild(btn);
|
||||
if (existingClose) {
|
||||
// Replace the existing close button with our grouped version
|
||||
existingClose.remove();
|
||||
btnGroup.appendChild(closeBtn);
|
||||
}
|
||||
headerContainer.appendChild(btnGroup);
|
||||
} else {
|
||||
// Standard modal: make h3 flex and append button
|
||||
// Standard modal: make h3 flex and append buttons
|
||||
header.style.display = 'flex';
|
||||
header.style.alignItems = 'center';
|
||||
btn.style.marginLeft = 'auto';
|
||||
header.appendChild(btn);
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.style.cssText = 'display:flex;align-items:center;gap:4px;margin-left:auto;';
|
||||
btnGroup.appendChild(btn);
|
||||
btnGroup.appendChild(closeBtn);
|
||||
header.appendChild(btnGroup);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -362,6 +362,7 @@ async _setupDesktopAppPrefs() {
|
|||
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; }
|
||||
|
|
@ -369,6 +370,7 @@ async _setupDesktopAppPrefs() {
|
|||
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) {
|
||||
|
|
@ -403,6 +405,11 @@ async _setupDesktopAppPrefs() {
|
|||
}
|
||||
} 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 ──────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ _setupSocketListeners() {
|
|||
} else {
|
||||
document.getElementById('admin-mod-panel').style.display = (canModerate || this._hasPerm('manage_emojis') || this._hasPerm('manage_soundboard')) ? 'block' : 'none';
|
||||
}
|
||||
document.getElementById('sidebar-members-btn').style.display = (this.user.isAdmin || canModerate || this._hasPerm('view_all_members')) ? '' : 'none';
|
||||
document.getElementById('sidebar-members-btn').style.display = (this.user.isAdmin || canModerate || this._hasPerm('view_all_members') || this._hasPerm('view_channel_members')) ? '' : 'none';
|
||||
});
|
||||
|
||||
// Roles updated (from admin assigning/revoking)
|
||||
|
|
@ -70,7 +70,7 @@ _setupSocketListeners() {
|
|||
const canCreateChannel = this.user.isAdmin || this._hasPerm('create_channel');
|
||||
document.getElementById('admin-controls').style.display = canCreateChannel ? 'block' : 'none';
|
||||
document.getElementById('admin-mod-panel').style.display = (canModerate || this._hasPerm('manage_emojis') || this._hasPerm('manage_soundboard')) ? 'block' : 'none';
|
||||
document.getElementById('sidebar-members-btn').style.display = (this.user.isAdmin || canModerate || this._hasPerm('view_all_members')) ? '' : 'none';
|
||||
document.getElementById('sidebar-members-btn').style.display = (this.user.isAdmin || canModerate || this._hasPerm('view_all_members') || this._hasPerm('view_channel_members')) ? '' : 'none';
|
||||
this._showToast(t('toasts.roles_updated'), 'info');
|
||||
});
|
||||
|
||||
|
|
@ -152,6 +152,11 @@ _setupSocketListeners() {
|
|||
setTimeout(() => {
|
||||
if (this.socket && !this.socket.connected) this.socket.connect();
|
||||
}, 2500);
|
||||
// Browsers don't compute layout accurately while a tab is hidden, so
|
||||
// scrollToBottom during a background reconnect often undershoots.
|
||||
// Re-scroll now that the tab is visible and layout is correct.
|
||||
if (this._coupledToBottom) this._scrollToBottom(true);
|
||||
|
||||
// Skip heavy refresh if we just handled a 'connect' event (avoids doubled emits)
|
||||
const sinceLast = Date.now() - (this._lastConnectTime || 0);
|
||||
if (sinceLast < 3000) return;
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ class VoiceManager {
|
|||
|
||||
this.rtcConfig = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
{ urls: 'stun:stun.stunprotocol.org:3478' },
|
||||
{ urls: 'stun:stun.nextcloud.com:3478' }
|
||||
]
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -370,6 +370,7 @@
|
|||
"mention_everyone": "Mention @everyone",
|
||||
"view_history": "View Message History",
|
||||
"view_all_members": "View All Server Members",
|
||||
"view_channel_members": "View Channel Members",
|
||||
"manage_emojis": "Manage Custom Emojis",
|
||||
"manage_soundboard": "Manage Soundboard",
|
||||
"promote_user": "Promote Users",
|
||||
|
|
@ -1358,6 +1359,7 @@
|
|||
},
|
||||
"all_members": {
|
||||
"title": "All Members",
|
||||
"channel_title": "Channel Members",
|
||||
"search_placeholder": "Search users...",
|
||||
"filter_all": "All",
|
||||
"filter_online": "Online",
|
||||
|
|
|
|||
|
|
@ -309,10 +309,11 @@ app.get('/api/ice-servers', (req, res) => {
|
|||
const user = token ? verifyToken(token) : null;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const iceServers = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
];
|
||||
// STUN_URLS env var: comma-separated list of STUN URIs to override defaults
|
||||
const stunUrls = process.env.STUN_URLS
|
||||
? process.env.STUN_URLS.split(',').map(u => u.trim()).filter(Boolean)
|
||||
: ['stun:stun.stunprotocol.org:3478', 'stun:stun.nextcloud.com:3478'];
|
||||
const iceServers = stunUrls.map(urls => ({ urls }));
|
||||
|
||||
const turnUrl = process.env.TURN_URL;
|
||||
if (turnUrl) {
|
||||
|
|
|
|||
|
|
@ -437,7 +437,7 @@ function initDatabase() {
|
|||
const channelModPerms = [
|
||||
'kick_user', 'mute_user', 'delete_message', 'pin_message',
|
||||
'manage_sub_channels', 'rename_sub_channel', 'delete_lower_messages',
|
||||
'upload_files', 'use_voice', 'view_history', 'manage_music_queue',
|
||||
'upload_files', 'use_voice', 'view_history', 'view_channel_members', 'manage_music_queue',
|
||||
'delete_own_messages', 'edit_own_messages'
|
||||
];
|
||||
channelModPerms.forEach(p => insertPerm.run(channelMod.lastInsertRowid, p));
|
||||
|
|
@ -480,6 +480,9 @@ function initDatabase() {
|
|||
)
|
||||
`);
|
||||
|
||||
// ── Prevent future NULL-duplicate inserts with a functional unique index ──
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_user_roles_no_dupes ON user_roles(user_id, role_id, COALESCE(channel_id, -1))');
|
||||
|
||||
// ── Migration: custom_level column on user_roles for per-assignment level overrides ──
|
||||
try {
|
||||
db.prepare('SELECT custom_level FROM user_roles LIMIT 0').get();
|
||||
|
|
|
|||
|
|
@ -343,7 +343,7 @@ const VALID_ROLE_PERMS = [
|
|||
'pin_message', 'archive_messages', 'kick_user', 'mute_user', 'ban_user',
|
||||
'rename_channel', 'rename_sub_channel', 'set_channel_topic', 'manage_sub_channels',
|
||||
'create_channel', 'create_temp_channel', 'upload_files', 'use_voice', 'use_tts', 'manage_webhooks', 'mention_everyone', 'view_history',
|
||||
'view_all_members', 'manage_emojis', 'manage_soundboard', 'manage_music_queue',
|
||||
'view_all_members', 'view_channel_members', 'manage_emojis', 'manage_soundboard', 'manage_music_queue',
|
||||
'promote_user', 'transfer_admin', 'manage_roles', 'manage_server', 'delete_channel'
|
||||
];
|
||||
|
||||
|
|
@ -1317,6 +1317,20 @@ function setupSocketHandlers(io, db) {
|
|||
voiceUsers.delete(code);
|
||||
activeMusic.delete(code);
|
||||
musicQueues.delete(code);
|
||||
// Auto-delete temporary voice channels when pruning empties the room
|
||||
try {
|
||||
const ch = db.prepare('SELECT id, is_temp_voice FROM channels WHERE code = ?').get(code);
|
||||
if (ch && ch.is_temp_voice) {
|
||||
db.prepare('DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)').run(ch.id);
|
||||
db.prepare('DELETE FROM pinned_messages WHERE channel_id = ?').run(ch.id);
|
||||
db.prepare('DELETE FROM messages WHERE channel_id = ?').run(ch.id);
|
||||
db.prepare('DELETE FROM channel_members WHERE channel_id = ?').run(ch.id);
|
||||
db.prepare('DELETE FROM channels WHERE id = ?').run(ch.id);
|
||||
io.emit('channel-deleted', { code, reason: 'temp-empty' });
|
||||
channelUsers.delete(code);
|
||||
console.log(`[Temporary] Temp voice channel "${code}" deleted (pruned empty)`);
|
||||
}
|
||||
} catch { /* column may not exist yet */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3401,11 +3415,15 @@ function setupSocketHandlers(io, db) {
|
|||
|
||||
socket.on('delete-channel', (data) => {
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'delete_channel')) {
|
||||
const delCode = typeof data.code === 'string' ? data.code.trim() : '';
|
||||
// Allow temp channel creators to delete their own temp channels
|
||||
const delCh = delCode ? db.prepare('SELECT created_by, is_temp_voice FROM channels WHERE code = ?').get(delCode) : null;
|
||||
const isOwnTemp = delCh && delCh.is_temp_voice && delCh.created_by === socket.user.id;
|
||||
if (!socket.user.isAdmin && !isOwnTemp && !userHasPermission(socket.user.id, 'delete_channel')) {
|
||||
return socket.emit('error-msg', 'Only admins can delete channels');
|
||||
}
|
||||
|
||||
const code = typeof data.code === 'string' ? data.code.trim() : '';
|
||||
const code = delCode;
|
||||
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
|
||||
const channel = db.prepare('SELECT * FROM channels WHERE code = ?').get(code);
|
||||
if (!channel) return;
|
||||
|
|
@ -5776,16 +5794,41 @@ function setupSocketHandlers(io, db) {
|
|||
const canMod = isAdmin || userHasPermission(socket.user.id, 'kick_user') || userHasPermission(socket.user.id, 'ban_user');
|
||||
const canSeeAll = canMod || userHasPermission(socket.user.id, 'view_all_members');
|
||||
|
||||
if (!canSeeAll) return cb({ error: 'Permission denied' });
|
||||
// Channel-scoped member viewing: if the caller can't see ALL members,
|
||||
// check if they have view_channel_members for the requested channel.
|
||||
let channelOnly = null; // null = all members, otherwise restrict to this channel's members
|
||||
if (!canSeeAll) {
|
||||
const channelCode = data && typeof data.channelCode === 'string' ? data.channelCode : null;
|
||||
if (channelCode) {
|
||||
const ch = db.prepare('SELECT id FROM channels WHERE code = ? AND is_dm = 0').get(channelCode);
|
||||
if (ch && userHasPermission(socket.user.id, 'view_channel_members', ch.id)) {
|
||||
channelOnly = ch.id;
|
||||
}
|
||||
}
|
||||
if (channelOnly === null) return cb({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
try {
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.username, COALESCE(u.display_name, u.username) as displayName,
|
||||
u.is_admin, u.created_at, u.avatar, u.avatar_shape, u.status, u.status_text
|
||||
FROM users u
|
||||
LEFT JOIN bans b ON u.id = b.user_id
|
||||
ORDER BY u.created_at DESC
|
||||
`).all();
|
||||
let users;
|
||||
if (channelOnly) {
|
||||
// Only fetch members of the specific channel
|
||||
users = db.prepare(`
|
||||
SELECT u.id, u.username, COALESCE(u.display_name, u.username) as displayName,
|
||||
u.is_admin, u.created_at, u.avatar, u.avatar_shape, u.status, u.status_text
|
||||
FROM users u
|
||||
JOIN channel_members cm ON u.id = cm.user_id
|
||||
WHERE cm.channel_id = ?
|
||||
ORDER BY u.created_at DESC
|
||||
`).all(channelOnly);
|
||||
} else {
|
||||
users = db.prepare(`
|
||||
SELECT u.id, u.username, COALESCE(u.display_name, u.username) as displayName,
|
||||
u.is_admin, u.created_at, u.avatar, u.avatar_shape, u.status, u.status_text
|
||||
FROM users u
|
||||
LEFT JOIN bans b ON u.id = b.user_id
|
||||
ORDER BY u.created_at DESC
|
||||
`).all();
|
||||
}
|
||||
|
||||
// Build online set from connected sockets
|
||||
const onlineIds = new Set();
|
||||
|
|
@ -5858,6 +5901,7 @@ function setupSocketHandlers(io, db) {
|
|||
cb({
|
||||
members,
|
||||
total: members.length,
|
||||
channelOnly: !!channelOnly,
|
||||
allChannels: canMod ? allChannels : undefined,
|
||||
callerPerms: {
|
||||
isAdmin,
|
||||
|
|
@ -6104,7 +6148,7 @@ function setupSocketHandlers(io, db) {
|
|||
.forEach(p => insertPerm.run(serverMod.lastInsertRowid, p));
|
||||
|
||||
const channelMod = insertRole.run('Channel Mod', 25, 'channel', '#2ecc71');
|
||||
['kick_user','mute_user','delete_message','pin_message','manage_sub_channels','rename_sub_channel','delete_lower_messages','upload_files','use_voice','view_history','manage_music_queue','delete_own_messages','edit_own_messages']
|
||||
['kick_user','mute_user','delete_message','pin_message','manage_sub_channels','rename_sub_channel','delete_lower_messages','upload_files','use_voice','view_history','view_channel_members','manage_music_queue','delete_own_messages','edit_own_messages']
|
||||
.forEach(p => insertPerm.run(channelMod.lastInsertRowid, p));
|
||||
|
||||
const userRole = insertRole.run('User', 1, 'server', '#95a5a6');
|
||||
|
|
@ -6205,6 +6249,7 @@ function setupSocketHandlers(io, db) {
|
|||
FROM user_roles ur
|
||||
JOIN roles r ON ur.role_id = r.id
|
||||
WHERE ur.user_id = ?
|
||||
GROUP BY ur.role_id, COALESCE(ur.channel_id, -1)
|
||||
`).all(m.id);
|
||||
|
||||
users.push({
|
||||
|
|
|
|||
|
|
@ -1415,12 +1415,12 @@
|
|||
</div>
|
||||
|
||||
<div class="download-card fade-in">
|
||||
<h2>⬡ Haven Server — v2.9.6</h2>
|
||||
<h2>⬡ Haven Server — v2.9.7</h2>
|
||||
<p class="download-version">Latest stable release · Windows, macOS & Linux · ~5 MB</p>
|
||||
|
||||
<div class="download-btn-group">
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.6.zip" class="btn btn-primary download-main">
|
||||
<span class="icon">⬇</span> Download v2.9.6 (.zip)
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip" class="btn btn-primary download-main">
|
||||
<span class="icon">⬇</span> Download v2.9.7 (.zip)
|
||||
</a>
|
||||
<div class="download-alt-links">
|
||||
<a href="https://github.com/ancsemi/Haven" target="_blank">⛭ View on GitHub</a>
|
||||
|
|
@ -1437,7 +1437,11 @@
|
|||
<div class="version-list">
|
||||
<div class="version-list-inner">
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.9.6</span><span class="v-tag latest">Latest</span></div>
|
||||
<div><span class="v-name">v2.9.7</span><span class="v-tag latest">Latest</span></div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.9.6</span> — Custom Terms of Service, unpin fix</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.6.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
|
|
|
|||
Loading…
Reference in a new issue