Compare commits

...

95 commits
v2.9.7 ... main

Author SHA1 Message Date
ancsemi
1f0d8f2006 Add channel/message links + configurable admin backup checkboxes (#5266)
Channel links: right-click any channel or DM to copy a deep link. Message toolbar gets a copy-link button that jumps to the message after navigating. Links survive login via sessionStorage handoff (same pattern as invite codes).

Configurable backup: replaces the structure/full split with a checkbox group for channels/roles, users (sanitized), server settings, messages, and uploaded files. Server endpoint accepts ?include=channels,users,settings,messages,files; old ?mode=structure|full still works.
2026-04-21 09:29:28 -04:00
ancsemi
9f2f4dfd74 SSO consent: tighten validate timeout (4s) and watchdog (5s), fall back to cached profile if validate is slow 2026-04-21 09:29:04 -04:00
ancsemi
feb357bd9f Server list: preserve subpath URLs when normalizing (e.g. https://host/community) 2026-04-21 09:29:00 -04:00
ancsemi
bb02f49526 fix(servers): cache-bust server icon URLs to bypass stale pre-CORS cache (#5240)
Browsers may have cached an icon response without CORS headers from a pre-fix load. Even after the server now sets Cross-Origin-Resource-Policy and Vary: Origin correctly, that stale cache entry can still be served for new crossorigin requests, producing 'No Access-Control-Allow-Origin' errors. Appending a stable ?_cb=cors2 marker to icon URLs forces a fresh fetch the first time and gives the new response a clean cache key.
2026-04-21 07:53:33 -04:00
ancsemi
0cacae73b3 feat(admin): remote server backup and restore (#5266)
Adds admin-only endpoints to download a backup of the server (structure-only or full DB+uploads zip) and to upload a full backup to restore from. Useful for hosts without shell access to the machine. Adds a Backup section to the Admin settings tab with download buttons and a restore upload field. Restore stages the data and exits the process so a supervisor (Docker, systemd, installer service) restarts Haven; the previous DB and uploads are preserved as .pre-restore copies for one cycle.
2026-04-21 07:52:23 -04:00
ancsemi
dd8258d46b Set Cross-Origin-Resource-Policy and Vary on image/health responses for cross-server icon loading 2026-04-20 21:15:17 -04:00
ancsemi
83fbfb5fd4 feat: add threads, toolbar customization, and SSO/auth flow improvements 2026-04-20 18:28:50 -04:00
ancsemi
47454daead Revert "Update community server link to vanity invite URL, remove manual channel code box"
This reverts commit e59a414bb6.
2026-04-20 08:48:11 -04:00
ancsemi
e59a414bb6 Update community server link to vanity invite URL, remove manual channel code box 2026-04-20 07:54:38 -04:00
ancsemi
dd2df1727a fix: auto-inject version into cache-busting query strings
The query strings in app.html were stuck at v2.x, so Electron and
aggressive caches served stale JS/CSS even after a server update.

The /app route now reads app.html and replaces all ?v= strings with
the current package.json version, ensuring clients always fetch fresh
assets after an update.
2026-04-19 19:15:29 -04:00
ancsemi
e6a0caa0e7 docs: update download pages to v3.4.0 2026-04-19 16:57:53 -04:00
ancsemi
8715392e50 docs: update CHANGELOG for v3.4.0 2026-04-19 16:57:50 -04:00
ancsemi
0c2b4a8be4 chore: bump version to 3.4.0 2026-04-19 16:57:47 -04:00
ancsemi
17519d5a09 Security: validate reply-to channel boundary and add WebRTC payload size limits
- Reply-to references are now checked to ensure the target message
  belongs to the same channel, preventing cross-channel data leaks.
- History reply context queries also scoped to current channel.
- WebRTC offer/answer capped at 16 KB, ICE candidates at 2 KB to
  prevent oversized relay payloads.
2026-04-19 16:49:33 -04:00
ancsemi
b56dff0535 Decouple event sounds from master notifications toggle (#5264)
Mentions, replies, DMs, sent, join, and leave sounds now play
independently of the Notifications checkbox. Only regular message
pings/announcements are gated by the master toggle. Voice UI cues
(mute, deafen, etc.) also always play.
2026-04-19 16:49:27 -04:00
ancsemi
76336b74fd Add crossorigin attribute to server icon images for cross-origin CORS loading (#5240) 2026-04-19 16:49:21 -04:00
ancsemi
f05a62d14e fix: use server fingerprint to reliably hide self from sidebar
The origin-based filter missed cases where the same server was stored
under a different URL (e.g. public domain vs localhost).

- Generate a unique server_fingerprint UUID in server_settings on first run
- Include fingerprint in /api/health response
- Client fetches own fingerprint on init, stores it in ServerManager
- Health checks store each server's fingerprint in statusCache
- Sidebar, manage-servers, and mobile views filter by fingerprint match
- Re-render triggered once selfFingerprint resolves to avoid flash
2026-04-19 15:46:37 -04:00
ancsemi
7f866838ce fix: filter current server from sidebar and respect removals in Desktop bridge
- _renderServerBar(), _renderManageServersList(), _renderMobileSidebarServers()
  now filter out servers matching window.location.origin
- Desktop bridge in _setupServerBar() checks removed set before merging
  Electron server history, preventing deleted servers from reappearing
2026-04-19 14:22:35 -04:00
ancsemi
032b4aee6a fix: normalize server URLs to origin and persist removals across sync
- add _normalizeUrl() helper that strips paths/trailing slashes/default ports
- add() now normalizes URLs to prevent duplicates from URL variants
- add() clears removed set when user explicitly re-adds a server
- remove() now calls markRemoved() so deleted servers stay deleted
- markRemoved() stores both exact URL and normalized origin
- syncWithServer() checks both URL and normalized origin against removed set
2026-04-19 14:22:28 -04:00
ancsemi
28108dae56 Add quote button to message toolbar
Clicking the speech-bubble icon inserts the message as a blockquote
(> prefix) with @author into the input. Translations added to all
six locale files.
2026-04-19 13:28:47 -04:00
ancsemi
923576a015 Up arrow edits last message + toggleable setting
Press Up arrow on an empty input to edit your most recent message.
Toggle available in Settings > Chat (on by default).
2026-04-19 13:28:41 -04:00
ancsemi
b964b1c23c Skip desktop app promo on mobile/tablet devices
Users on Android/iPhone/iPad no longer see the 'Desktop app available'
banner and popup. Electron detection was already in place.
2026-04-19 13:28:34 -04:00
ancsemi
c48534b6ae Ship donor-order.json with all installs (remove from gitignore)
The sort toggle (Early Believers / Biggest Thanks!) was invisible on
other servers because donor-order.json was gitignored. Now all Haven
installs ship with the file. No donation amounts are exposed, only
the ordering of names.
2026-04-19 13:28:28 -04:00
ancsemi
69b03592bb fix: 'pull icon from server' fails cross-origin
Add CORS headers to /uploads for image files so cross-origin icon
fetching works. Also update icon URL on every health check instead of
only when icon was previously unset, allowing icon changes to propagate.
2026-04-19 03:20:22 -04:00
ancsemi
b02b9a9ff7 fix: server list sync broken on page refresh / auto-login
Persist the E2E wrapping key to localStorage as haven_sync_key so
server list sync works on auto-login (no password entry). Previously
the key was only in sessionStorage, lost on tab close/refresh.
Clear haven_sync_key on logout and account deletion.
2026-04-19 03:20:12 -04:00
ancsemi
316494f877 fix: SSO consent page stuck at 'Checking login status...'
Wrap localStorage access in try/catch to prevent silent SecurityError
crashes in popups/Electron. Add /api/auth/validate endpoint and use
server-side token verification as primary check with localStorage fallback.
2026-04-19 03:20:05 -04:00
ancsemi
fe5a17d178 feat: SSO 'Link a Server' shows recent servers dropdown
Populate datalist from localStorage haven_servers so users see their
recent servers when entering the home server URL for SSO.
2026-04-19 03:19:59 -04:00
ancsemi
93479775ae feat: bot API - delete messages and play soundboard sounds
Add DELETE /api/webhooks/:token/messages/:messageId for bots to delete messages.
Add POST /api/webhooks/:token/sounds for bots to trigger soundboard sounds.
Add client-side play-sound socket handler for bot-triggered sounds.
2026-04-19 03:19:53 -04:00
ancsemi
00c6689c89 Add Bot Developer Guide section to GUIDE.md, update FAQ link in README 2026-04-19 03:19:47 -04:00
ancsemi
5a02e41e49 fix: prevent stale socket from evicting active user in voice channel
When a user joins voice from a second client (B-2), the server kicks
the old socket (B-1) via handleVoiceLeave, then adds B-2's entry.
B-1's client then receives voice-kicked, calls leave(), and emits
voice-leave — which triggered handleVoiceLeave again on B-1's socket.
Because the check was only voiceRoom.has(userId), it matched B-2's
entry and deleted it, making everyone think B left.

Fix: check entry.socketId === socket.id before proceeding with cleanup.
If the socket is stale (superseded by another client), just remove it
from the socket.io room and return without touching the voiceUsers map.
2026-04-19 02:17:39 -04:00
ancsemi
a5df2d9b13 chore: bump version to 3.3.0, update changelog and download pages 2026-04-18 21:12:53 -04:00
ancsemi
9f5da48976 Improve server-list sync: periodic resync + tab-focus trigger
- Re-sync encrypted server list every 5 minutes (if wrapping key available)
- Trigger sync when tab becomes visible (catches changes from other devices)
- Both fire only when e2e wrapping key is in memory (password-based login)
2026-04-18 20:27:46 -04:00
ancsemi
564bae8750 Fix iOS Safari safe-area overlap on mobile
- JS probes actual env(safe-area-inset-*) values via hidden element
- Sets --safe-top / --safe-bottom CSS custom properties with device-
  specific minimum floors (47px for notched iPhones, 20px for older)
- Targeted .is-ios / .is-android body classes for platform-specific CSS
- Hardened channel-header and message-input-area padding via !important
  to override theme specificity issues
- Handles both Safari browser and PWA standalone modes
2026-04-18 20:27:39 -04:00
ancsemi
e4db71dd09 Add per-event volume sliders for join/leave sounds
- Join Volume and Leave Volume sliders in Sounds settings
- Each uses independent localStorage key (haven_notif_join_volume, haven_notif_leave_volume)
- Follows same pattern as @Mention and Reply volume controls
- Addresses ancsemi/Haven-Desktop#23
2026-04-18 20:27:35 -04:00
Reverb
0d1858f747
fix: disable CSP upgrade-insecure-requests when FORCE_HTTP=true (#5258)
Helmet 8.x automatically appends 'upgrade-insecure-requests' to every
Content-Security-Policy response, even when custom directives are provided.

When a Haven server runs with FORCE_HTTP=true (plain HTTP, no TLS),
this directive causes browsers and Electron clients to upgrade all
http:// subresource requests (CSS, JS, fonts) to https://. Since the
server has no TLS, those requests fail silently, resulting in a broken
UI with no styling or JavaScript.

Fix: spread { upgradeInsecureRequests: null } into CSP directives when
FORCE_HTTP=true, which tells helmet to omit the directive entirely.

The HSTS header is already correctly disabled for FORCE_HTTP=true (line
121), but the CSP directive was missed.

Co-authored-by: reverb256 <reverb256@users.noreply.github.com>
2026-04-17 01:11:25 -04:00
ancsemi
f2827690a0 feat: last read message indicator (#5259)
- Server: include lastReadMessageId in message-history response by querying
  read_positions for the user's last read position in the channel
- Client: render a 'NEW MESSAGES' divider between the last read message and
  the first unread message on initial channel load
- Auto-scroll to the divider so users see where they left off
- Skip divider when user is fully caught up or all messages are unread
- CSS: styled divider with accent-colored line and label (Teams-style)
2026-04-16 22:53:26 -04:00
ancsemi
8befde0b15 fix: properly clean up duplicate voice joins from multiple clients (#5247)
- Use handleVoiceLeave() on the old socket instead of manual map deletion,
  ensuring peer connections, screen shares, and webcams are fully cleaned up
- Emit 'voice-kicked' to the old client so it disconnects its local voice UI
- Add client-side voice-kicked handler that calls leave() and shows a toast
- Re-create voiceUsers map entry if handleVoiceLeave cleaned it up
2026-04-16 22:53:26 -04:00
ancsemi
f5e94877f0 fix: case-insensitive channel tag grouping (#5260)
- Server: when setting a category, reuse existing casing of matching tag
  (COLLATE NOCASE) so 'Gaming' and 'gaming' always share the same group
- Client sidebar: group channels by tag case-insensitively
- Client organize modal: deduplicate and filter tags case-insensitively
- Sub-channel tag headers: compare case-insensitively
2026-04-16 22:53:25 -04:00
Amnibro
2d82cc697c
fix(e2e): distinguish 'no backup' from 'unreachable' to prevent clobber (#5261)
`_fetchBackup`'s 5s timeout returned null for both server-confirmed empty
and request-timed-out. Flaky mobile reconnects during init could therefore
trigger step 1b to re-upload a stale local key, or step 3 to mint a fresh
keypair — either path overwrites a good server backup and splits the
account into two key universes. The tell is messages decrypting for one
exchange then going dark.

- `_fetchBackupWithState` returns explicit {status: 'present'|'none'|'unknown'},
  15s timeout, 1 retry. Legacy `_fetchBackup` shim preserved for compat.
- init() only re-uploads (step 1b) or generates (step 3) on affirmative
  'none'. On 'unknown' it bails out with ghostState=true so UI can prompt.
- Divergence check: when server has a backup but pub key differs from
  local, flag divergent=true for UI.
- 5-min IndexedDB-backed regenerate cooldown kills flap-regenerate storms.
- Server get-encrypted-key returns `state: 'present'|'empty'|'error'` plus
  the public key JWK so clients can detect divergence without a round-trip.
  Additive — legacy clients ignore the new fields.

Signed-off-by: Amnibro <99765883+Amnibro@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 22:37:44 -04:00
ancsemi
a9015373d1 chore: bump version to 3.2.0, update changelog and download pages 2026-04-16 18:00:19 -04:00
ancsemi
0c99b7ea68 fix: pinned message click now jumps to message even when scrolled out of DOM 2026-04-16 17:58:23 -04:00
ancsemi
183946c406 feat: Mark as Read context menu for channels and DMs 2026-04-16 17:58:18 -04:00
ancsemi
47c2bbe441 fix: iOS Safari mobile issues - double-tap, scroll, safe area, emoji picker, status picker 2026-04-15 18:48:41 -04:00
ancsemi
359f461ce8 fix: respect 'Don't show again' checkbox when closing promo modals via overlay click (#5257) 2026-04-15 18:48:37 -04:00
ancsemi
c022105478 chore: bump version to 3.1.1, update changelog and download pages 2026-04-15 01:18:59 -04:00
ancsemi
b189eaa69a feat: status bar default hidden, toggle tab, server URL with copy/privacy 2026-04-15 01:18:55 -04:00
ancsemi
e567d070ea fix: mobile image overlap — add flex-shrink:0 to messages, fix reply/text overflow 2026-04-15 01:18:50 -04:00
ancsemi
8005f9cdfd feat: move banner display settings (height, offset, header style) to client-side user settings
Banner image upload/clear remains admin-only. Display preferences
(height, offset, header mode) are now per-device via localStorage,
so users can customize their own view without affecting others.
2026-04-14 22:04:58 -04:00
ancsemi
f44a15b7c2 fix: mobile chat images — preserve aspect ratio, prevent squishing 2026-04-14 22:00:56 -04:00
ancsemi
161eca23e4 Update docs & website download links for v3.1.0 2026-04-14 21:44:28 -04:00
ancsemi
c5529da569 Update CHANGELOG for v3.1.0 2026-04-14 21:44:25 -04:00
ancsemi
61e5c7b922 Bump version to 3.1.0 2026-04-14 21:44:22 -04:00
ancsemi
fdfac9f79b fix: topic bar opacity making banner bleed through in Full mode
When no topic is set, the placeholder used opacity:0.4 on the entire
bar element, making its background semi-transparent. Changed to use
color:var(--text-muted) for the dimmed look instead, so the background
stays fully opaque.
2026-04-14 21:38:22 -04:00
ancsemi
f4dfa217a0 feat: banner header style dropdown — 4 modes
Replace overlay checkbox with a dropdown offering:
- Full Header: opaque bg, banner hidden behind header/topic
- Shaded Header: translucent dark tint, banner visible
- Minimal Header: transparent bars, pill-shaped glassmorphism
  behind channel name, action buttons, and voice controls
- Transparent Header: fully clear, no shading anywhere

Backward compatible: old banner_overlay_header=true maps to 'shaded'
New setting: banner_header_mode (full/shaded/minimal/transparent)
2026-04-14 21:29:24 -04:00
ancsemi
1baf463458 fix: banner stacking — header/topic above banner with z-index
The banner (position:absolute, z-index:0) was painting ON TOP of the
header because the header had no positioning. Fixed by giving header
and topic-bar position:relative + z-index:1 under .has-banner.

Overlay OFF: opaque bg + z-index:1 = banner completely hidden
Overlay ON: semi-transparent dark bg + z-index:1 = banner visible,
text readable against dark tint
2026-04-14 21:07:25 -04:00
ancsemi
075243d816 fix: remove erroneous solid backgrounds from message-area and welcome-screen
Banner is always behind everything. Only header + topic-bar get solid
backgrounds when overlay is off. Chat area stays transparent so the
banner shows through as a decorative background.
2026-04-14 20:51:48 -04:00
ancsemi
cad5eb2fc9 fix: banner non-overlay solid backgrounds + search result click navigation
Banner:
- Added solid backgrounds to message-area and welcome-screen under .has-banner
  so the banner is fully covered when overlay is OFF
- Header, topic-bar, message-area, welcome-screen all get solid bg when banner
  exists + overlay disabled; overlay ON still makes header/topic transparent

Search:
- Fixed _jumpToMessage selector: was finding search result item instead of
  actual message (missing #messages prefix)
- Search panel now closes when clicking a result so user sees the jump
2026-04-14 20:43:32 -04:00
ancsemi
593e269030 fix: banner display rework — solid header when overlay off, vertical offset slider
- Reverted solid backgrounds on header/welcome/message-area that broke banner visual
- Added .has-banner class: header & topic-bar get solid bg by default (covers banner)
- .has-banner-overlay still makes header transparent (Discord-style) — unchanged
- Added vertical offset slider (0-100%) to shift banner image focus point
- Fixed range slider thumb alignment (1px border correction)
- New server setting: banner_offset (validated 0-100)
2026-04-14 20:27:36 -04:00
ancsemi
612440ec65 Fix banner bleeding through header, fix range slider thumb alignment
- Add explicit background: var(--bg-primary) to .channel-header,
  .message-area, and .welcome-screen so they're opaque above the banner
  when overlay mode is off (overlay mode still overrides to transparent)
- Add global range input thumb styling (::-webkit-slider-thumb and
  ::-moz-range-thumb) with proper vertical centering via margin-top calc
  so the dot sits centered on the 6px track instead of offset
2026-04-14 20:14:35 -04:00
ancsemi
7a640d4662 Banner always behind content, add height slider
- Banner is now always absolutely positioned at z-index:0 behind all
  content (header, messages, streams, webcams, welcome screen all at z-index:1)
- Nothing fights the banner for space regardless of overlay mode
- 'Overlay header' toggle only controls header transparency, not positioning
- New banner height slider (80-400px) lets admins control how far down
  the banner extends without stretching the image (object-fit: cover)
- Live preview while dragging slider, persisted on release
- Server setting 'banner_height' added to allowed keys (validated 80-400)
2026-04-14 19:34:44 -04:00
ancsemi
4b90088094 Rework banner: default below header at 80% opacity, optional overlay mode
- Banner now renders inside message-area (below header/topic) by default
- Banner image at 80% opacity with bottom gradient fade
- New 'Banner overlays header' toggle in admin settings panel
  - When enabled, banner moves to absolute overlay behind header (Discord-style)
  - Header/topic become transparent with text-shadow and glassmorphism actions box
- Fix upload rejection: detect image format from magic bytes alone instead of
  requiring MIME type to match (browser MIME detection is unreliable)
- Restrict file picker accept attribute to jpg/png/gif/webp
- Server setting 'banner_overlay_header' added to allowed keys with validation
2026-04-14 19:21:52 -04:00
ancsemi
a0db79c344 Redesign server banner: background overlay behind header with gradient fade
- Move banner from block element inside message-area to absolutely-positioned
  overlay behind channel header (Discord-style)
- Add gradient fade at bottom edge blending into background
- Header/topic-bar made transparent with text-shadow for readability
- Header actions box gets glassmorphism (backdrop-filter blur + tint)
- Message area elevated above banner via z-index
- Mobile responsive: smaller banner (120px) on tablets, 80px on landscape phones
- Theme compatible: !important overrides ensure transparency on all themes
2026-04-14 18:47:12 -04:00
ancsemi
b764c35f83 Fix role icon upload (field name + response mismatch) and auto-resize to 16x16 2026-04-14 18:32:32 -04:00
ancsemi
39c06f09a2 Detect E2E encrypted envelopes in push/browser notifications, show generic text instead of raw JSON (#5256) 2026-04-14 18:32:28 -04:00
ancsemi
35e0fd668b Store server icon thumbnails in sync bundle so icons travel across servers (#5240) 2026-04-14 18:32:24 -04:00
ancsemi
6a6fc695b0 fix: Safari iOS layout issues — safe-area, keyboard, dots overlap
- Move safe-area-inset-top from #app to channel-header and right-sidebar
  so iOS Safari honours it reliably with viewport-fit=cover
- Add safe-area-inset-bottom to message-input-area at tablet breakpoint
- Add min-height to compact messages on touch so per-message dots
  buttons don't visually overlap adjacent rows
- Extend iOS keyboard resize fix to Safari browser (was standalone only)
2026-04-14 15:03:11 -04:00
ancsemi
b208486a98 fix: guard non-existent tables in delete-user transactions (#5252)
The delete-user and self-delete-account handlers referenced uploads,
channel_emojis, and webhook_configs tables that were never created,
causing a SqliteError crash when trying to remove a user.  Uses a
cached table-existence check so the UPDATE is skipped safely when the
table doesn't exist yet.
2026-04-14 15:03:04 -04:00
ancsemi
7b3c3a7d60 Update docs & website download links for v3.0.0 2026-04-14 01:13:43 -04:00
ancsemi
753e05ab46 Update CHANGELOG for v3.0.0 2026-04-14 01:13:38 -04:00
ancsemi
5e3ef96885 Bump version to 3.0.0 2026-04-14 01:13:33 -04:00
ancsemi
c3b217323b Merge feature/sso: SSO registration, list fix, slider fix 2026-04-14 01:10:03 -04:00
ancsemi
47a4bc1959 Fix YouTube seek slider thumb alignment (#5250) 2026-04-14 01:09:54 -04:00
ancsemi
4ab8275058 Fix ordered list renumbering: preserve start number in markdown 2026-04-14 01:09:50 -04:00
ancsemi
2c8a941e93 Implement SSO: register via identity from another Haven server
- Server-side: consent page, approve/authenticate endpoints, CORS, rate limiting
- Server-side: downloadSSOAvatar helper (validates magic bytes, stores locally)
- Registration accepts optional ssoProfilePicture URL for avatar import
- Client-side: Link Server tab with two-step flow (connect → set password)
- 32-byte crypto auth codes, 60s TTL, one-time use
- E2E encryption preserved (password still required on every server)
2026-04-14 00:20:55 -04:00
ancsemi
5bb674711c Search filters, reply notifications, settings tabs, UI polish
- Advanced search: from:user, in:#channel, has:image/file/link/video filters
- Reply notifications: separate sound/volume for replies to your messages
- Settings reorganization: User/Admin tab split with tab bar
- Reply banner: pill-style design, moved inside message body
- Emoji picker: expanded food, activities, objects categories
- Scroll: fix tab-switch scroll position when browsing history
- Auth page: fix vertical centering on small screens
- Search bar: wider input, filter tag badges
- Fix corrupted English flag emoji in language selector
2026-04-14 00:09:38 -04:00
ancsemi
34a5fcd35d Update donor lists: add birdycrazy, fix sponsor sort order 2026-04-14 00:09:12 -04:00
ancsemi
af5b28e4be fix: jump-to-message for search results & replies, DM search notice, voice double-join guard
- Add _jumpToMessage() method that scrolls to a message if in DOM, or fetches
  messages around it from the server when it's outside the loaded history window.
  Used by both search result clicks (#5249) and reply banner clicks.
- Add 'around' parameter to get-messages handler to load messages centered on
  a target message ID, enabling jump-to-message for distant history.
- DM search now returns an explanatory notice instead of silently searching
  encrypted ciphertext (#5248).
- Prevent duplicate voice channel joins from multiple clients: voice-join now
  evicts the user's previous socket before adding the new one, and filters
  existingUsers to exclude self (#5247).
2026-04-13 19:57:49 -04:00
ancsemi
b3bc43cf67 docs: add Running Multiple Servers section to README 2026-04-13 18:52:59 -04:00
ancsemi
b3ac781061 fix: @mention and :emoji autocomplete now work in edited messages 2026-04-13 18:22:13 -04:00
ancsemi
21ce77aa42 fix: copy image now converts to PNG for clipboard compatibility (fixes #5246) 2026-04-13 18:20:46 -04:00
ancsemi
b5536f06da fix: increase mobile sidebar bottom padding for Android gesture bar 2026-04-13 18:19:24 -04:00
ancsemi
e3348dfc71 fix: DM sidebar names now update when a user changes display name 2026-04-13 18:17:55 -04:00
ancsemi
722878b780 fix: exclude donors modal from expand/close button injection 2026-04-13 09:16:54 -04:00
ancsemi
36eddeba5f v2.9.9 — encrypted server list sync, polls, jump-to-bottom, highlight markdown, emoji in edit mode 2026-04-13 01:41:45 -04:00
ancsemi
18efdfcadf fix: channels module export (was exports.register instead of exports) 2026-04-13 01:14:29 -04:00
ancsemi
695e0aad28 fix: send button 42x42, lightbox arrow nav, Safari PWA fixes, scroll-to-bottom, add-server centering
- Send button: 42x42px to match message bar height
- Lightbox: arrow key & button navigation between channel images
- Safari/iOS PWA: safe-area insets on sidebar/header, z-index fixes for input bleed-through, iOS keyboard handler condition fix
- Scroll-to-bottom: remove inline display:none that blocked CSS visibility toggle
- Add-server '+' button: fix text centering with flexbox
2026-04-13 01:00:46 -04:00
ancsemi
ce501736aa docs: update CHANGELOG for unreleased changes
Add entries for role display picker, welcome message, masked link
warning, admin password reset, crash log, event loop lag monitor,
dynamic memory watchdog, E2E pinned message fix, pinned panel stale
data fix, user deletion FK constraints, desktop shortcut recording.
2026-04-13 00:33:08 -04:00
ancsemi
86a3a5b3f1 ui: SVG icons for emoji and poll buttons, GIF hover fix
Replace emoji (😀) and poll (📊) buttons with inline SVGs for
consistent cross-platform rendering. Add poll button to win95
theme and mobile media query. Change GIF button hover from accent
color to text-primary for consistency.
2026-04-13 00:32:56 -04:00
ancsemi
85fdfdba79 feat: /poll slash command, fix DM scroll position
- /poll opens the poll creator modal from the message input
- Deferred re-scroll after renderMessages prevents DMs from
  landing mid-history when images/link previews/E2E decryption
  add height after the initial scrollToBottom
2026-04-13 00:31:36 -04:00
ancsemi
9a1ce54c84 feat: ==highlight== markdown formatting
Wrap text in double equals signs to render it with a translucent
yellow highlight background. Uses <mark> with .chat-highlight class.
2026-04-13 00:30:46 -04:00
ancsemi
a087b86f1b feat: emoji picker in message edit mode
The emoji picker now inserts into the active edit textarea when
editing a message. An emoji button appears in the edit action bar.
Picker auto-closes when the edit is cancelled.
2026-04-13 00:30:09 -04:00
ancsemi
32020e9c3c feat: jump-to-bottom button
Floating arrow button appears when scrolled 400px+ from bottom.
Clicking it smooth-scrolls to the latest message and re-couples
the auto-scroll. Hidden on channel switch.
2026-04-13 00:29:11 -04:00
ancsemi
c4d5af5a59 feat: encrypted server list sync across devices
Store an AES-256-GCM encrypted copy of the user's server list on
each server they log into. When logging in from a new device the
list is automatically restored. No server-to-server communication.

- GET/PUT /api/auth/user-servers endpoints (auth.js)
- encrypted_servers column migration (database.js)
- Client-side encrypt/decrypt/merge in ServerManager (servers.js)
- Post-login sync trigger via E2E wrapping key (app-platform.js)
- Push on add/remove/password-change (app-ui.js)
- Developer integration guide (docs/server-list-sync.md)
2026-04-13 00:28:16 -04:00
ancsemi
fad2a5d73e refactor: split socketHandlers.js into domain modules
Break 7515-line monolith into 12 focused modules:
- index.js (orchestrator, shared state, middleware, intervals)
- channels.js, messages.js, voice.js, music.js, users.js
- moderation.js, roles.js, admin.js (domain handlers)
- helpers.js, musicResolver.js, permissions.js (utilities)

Consolidates duplicate handler registrations, fixes shared
broadcastChannelLists debounce scope, fixes transferAdmin mutex.
2026-04-13 00:27:00 -04:00
ancsemi
6e97a7a35c v2.9.8 — read-only channels, server-relayed mic illumination, role permission row highlight 2026-04-12 00:04:01 -04:00
52 changed files with 14717 additions and 7987 deletions

BIN
.gitignore vendored

Binary file not shown.

View file

@ -11,6 +11,192 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Haven uses [Sema
---
## [3.5.0] — 2026-04-20
### Added
- **Threaded replies panel** — message threads now open in a dedicated right-side panel with parent context, inline reply flow, and live updates.
- **Thread previews in channel chat** — parent messages now show thread activity summaries with reply count, recent participants, and last activity timestamp.
- **Thread panel PiP mode and resize handle** — thread conversations can be popped out into a floating panel and resized for multitasking.
- **Toolbar icon and layout customization** — settings now include monochrome vs emoji toolbar styles, visible action slot count, and per-action order controls.
### Fixed
- **SSO approval reliability and feedback** — improved SSO consent/auth flow with clearer status messages, timeout handling, profile return via `postMessage`, and stronger fallback behavior.
- **Vanity invite continuity through auth redirects**`invite` query params now persist through login/register flows and redirect correctly into `/app`.
- **Thread-aware message queries** — primary channel history now excludes thread replies to prevent duplicate rendering and keep main timelines clean.
- **Cache-busting version query injection** — static asset version query strings are now auto-injected more reliably to reduce stale client bundles after updates.
### Changed
- **SSO response metadata** — SSO auth responses now include display name data and stricter CORS/origin handling for cross-origin auth handoff.
- **Database schema for threads** — added `messages.thread_id` migration and index to support efficient threaded message fetches.
---
## [3.4.0] — 2026-04-19
### Added
- **Quote button** — a quote button in the message toolbar inserts a formatted quote of the selected message into the input box.
- **Up-arrow to edit last message** — pressing up in an empty message input opens the last message you sent for editing. Toggleable in Settings.
- **Bot API: delete messages & play soundboard sounds** — bots can now delete messages and trigger soundboard sound playback via the API.
- **SSO recent-servers dropdown** — the SSO "Link a Server" page now shows a dropdown of recently visited servers for quick selection.
### Fixed
- **Event sounds decoupled from notifications toggle** — join/leave sounds now play regardless of whether the master notifications toggle is off. (#5264)
- **Server icon cross-origin loading** — server icons fetched from external origins now include the correct `crossorigin` attribute, preventing CORS errors. (#5240)
- **Server list hides current server reliably** — the server list sidebar now uses the server fingerprint to identify and hide the host server, fixing cases where it appeared in its own list.
- **Server list removals persist** — manually removed servers are now normalized by origin and persist across syncs; the Desktop bridge also respects removals.
- **Server list sync on page refresh / auto-login** — the encrypted server list now syncs correctly when the page reloads or the user auto-logs in.
- **SSO consent page "Checking login status..."** — the SSO consent page no longer gets stuck in a loading state after a session is already established.
- **Desktop app promo skipped on mobile/tablet** — the desktop app promotional modal no longer appears on mobile or tablet devices.
- **Stale socket evicting active voice users** — a stale socket reconnect no longer incorrectly removes an active user from a voice channel.
### Security
- **Reply-to channel boundary validation** — the server now validates that a reply target belongs to the same channel, preventing cross-channel reply injection.
- **WebRTC payload size limits** — enforced maximum payload sizes on WebRTC data channel messages to limit potential abuse.
---
## [3.3.0] — 2026-04-18
### Added
- **Last read message indicator** — a subtle divider marks where you left off when you return to a channel. (#5259)
- **Per-event volume sliders** — separate volume controls for join and leave notification sounds in User Settings.
- **Server-list sync improvements** — the encrypted server list now resyncs periodically and on tab focus, so your server list stays current across devices without a full reload.
### Fixed
- **iOS Safari safe-area overlap** — additional safe-area inset fixes on mobile Safari preventing content from being clipped by notches and home indicator.
- **CSP upgrade-insecure-requests with FORCE_HTTP** — the Content Security Policy no longer forces HTTPS upgrades when `FORCE_HTTP=true` is set, which was breaking HTTP-only installs. (#5258)
- **Duplicate voice joins** — properly cleans up stale state from a race condition where rapidly clicking join could register a client twice in the same voice channel. (#5247)
- **Case-insensitive channel tag grouping** — channel tags are now matched case-insensitively, so `[General]` and `[general]` are treated as the same group. (#5260)
- **E2E backup clobber prevention** — the encryption backup flow now correctly distinguishes between "no backup exists" and "backup server unreachable," preventing a reachability failure from overwriting a valid backup. (#5261)
---
## [3.2.0] — 2026-04-16
### Added
- **Mark as Read context menu** — right-click a channel or DM to mark it as read. The option only appears when the channel has unread messages. Clears the unread badge and updates the server-side read position.
### Fixed
- **Pinned message jump** — clicking a pinned message now correctly scrolls to and highlights it even when the message has been trimmed from the DOM (more than 100 messages back). Previously this would silently fail.
- **iOS Safari mobile issues** — fixed double-tap zoom, scroll momentum, safe area insets, emoji picker positioning, and status picker rendering on Safari iOS.
- **Promo modal dismiss** — clicking the overlay to close a promotional modal now correctly respects the "Don't show again" checkbox. (#5257)
---
## [3.1.1] — 2026-04-15
### Added
- **Status bar toggle tab** — a small `📊` tab appears in the bottom-right corner when the status bar is hidden, providing an obvious one-click way to reveal it.
- **Server URL in status bar** — the status bar now displays the server address with click-to-copy functionality. A privacy toggle lets you hide/show the URL (useful for streamers). Copying works even when the address is hidden.
### Changed
- **Status bar default** — the status bar (debug footer) is now **hidden by default** on web/mobile. Users can enable it from Settings → Layout or by clicking the toggle tab. Desktop app behavior is unchanged.
- **Banner display settings** — banner height, vertical offset, and header style settings are now stored client-side (per-user preference) instead of server-side, so each user can customize their own view.
### Fixed
- **Mobile image overlap** — images in chat messages no longer overlap with adjacent messages on mobile devices. Root cause: flex items in the message list could shrink below their content height; now prevented with `flex-shrink: 0`.
- **Mobile reply banner overflow** — reply banners on mobile now wrap properly instead of overflowing off-screen.
- **Mobile message text overflow** — long words and URLs in messages now break correctly on mobile instead of overflowing horizontally.
- **Status bar hidden on mobile** — the status bar was previously force-hidden via CSS on tablets and phones; it now respects the user's setting and condenses non-critical items at smaller breakpoints instead of disappearing entirely.
---
## [3.1.0] — 2026-04-14
### Added
- **Server banners** — servers can now have a banner image displayed at the top of the chat area. Includes overlay and non-overlay display modes, a header style dropdown with four options (Transparent, Tinted, Solid, Full), height and vertical offset sliders, and gradient fade for a polished look.
- **Server icon sync** — server icon thumbnails are now included in the encrypted sync bundle so server icons persist across devices. (#5240)
### Fixed
- **Role icon upload** — fixed role icon upload (field name mismatch and response handling) and added auto-resize to 16x16 for consistency.
- **E2E encrypted notification content** — push and browser notifications for end-to-end encrypted messages now show generic placeholder text instead of raw JSON envelopes. (#5256)
- **Safari iOS layout** — fixed safe-area insets, keyboard overlap, and navigation dot positioning on Safari iOS.
- **Delete-user transaction safety** — added guards for non-existent tables in delete-user database transactions to prevent errors on fresh installs. (#5252)
---
## [3.0.0] — 2026-04-14
### Added
- **SSO registration (Link Server)** — users can register on a new Haven server using their identity from another Haven server. The "Link Server" tab on the auth page walks through a two-step flow: connect to your home server, approve the identity share, then set a local password. Username and profile picture are imported; E2E encryption is preserved since a password is still required on every server. Server-side includes consent page, auth code approval, authenticate endpoints, CORS handling, rate limiting (5 req/min/IP), and secure avatar download with magic-byte validation.
- **Advanced search filters** — search now supports `from:username`, `in:#channel`, and `has:image/file/link/video` filters. Filter tags render as badges in the search bar.
- **Reply notifications** — replies to your messages now trigger a distinct notification sound with separate volume control, configurable in User Settings.
- **Settings tab reorganization** — the settings panel is now split into User and Admin tabs with a tab bar for cleaner navigation.
- **Running Multiple Servers** — new README section documenting how to run multiple Haven instances on the same machine.
### Changed
- **Reply banner redesign** — reply indicators now use a compact pill-style design placed inside the message body instead of above it.
- **Emoji picker expansion** — expanded food, activities, and objects categories in the emoji picker.
- **Search bar** — wider input field and visual filter tag badges.
### Fixed
- **Ordered list renumbering** — messages starting with `2.` or `3.` (etc.) no longer render as `1.` when sent as separate messages. The original number is now preserved via the HTML `start` attribute.
- **YouTube seek slider alignment** — the progress slider thumb now aligns correctly with the track bar. (#5250)
- **Jump-to-message for search results and replies** — clicking a search result or reply reference now correctly scrolls to and highlights the target message.
- **DM search notice** — search in DMs now shows an appropriate notice when no results are found.
- **Voice double-join guard** — prevented a race condition where rapidly clicking voice join could connect twice.
- **@mention and :emoji autocomplete in edit mode** — autocomplete now works when editing an existing message, not just when composing.
- **Copy image clipboard format** — copying an image from chat now converts to PNG for clipboard compatibility. (#5246)
- **Mobile sidebar padding** — increased bottom padding on mobile sidebar for Android gesture bar clearance.
- **DM sidebar name updates** — DM sidebar now reflects display name changes without requiring a page reload.
- **Donors modal expand button** — excluded the donors modal from the expand/close button injection.
- **Auth page centering** — fixed vertical centering on small screens.
- **Tab-switch scroll position** — switching tabs while browsing message history no longer resets scroll position.
- **English flag emoji** — fixed corrupted flag emoji in the language selector.
---
## [2.9.9] — 2026-04-13
### Added
- **Encrypted server list sync** — your server list and ordering now sync across devices via an encrypted key stored on the server. Adding, removing, or reordering servers on one device automatically carries over when you log in elsewhere.
- **Jump-to-bottom button** — a floating button appears when you scroll up in chat, letting you jump back to the newest messages with one click.
- **Emoji picker in edit mode** — the emoji picker is now available when editing a message, not just when composing a new one.
- **`==highlight==` markdown** — wrap text in double equals signs to render it with a highlight background.
- **`/poll` slash command** — create inline polls with `/poll "Question" "Option 1" "Option 2" ...`.
### Changed
- **SVG toolbar icons** — the emoji and poll buttons in the message toolbar now use crisp SVG icons instead of text/emoji characters.
- **Codebase modularization** — the monolithic socket handler has been split into focused domain modules (messages, channels, voice, admin, etc.) for maintainability.
### Fixed
- **DM scroll position** — switching to a DM conversation no longer starts at the wrong scroll position.
- **Send button sizing** — the send button is now a consistent 42×42 px.
- **Lightbox arrow navigation** — left/right arrows in the image lightbox now work correctly.
- **Safari PWA fixes** — various Safari-specific issues in Progressive Web App mode have been addressed.
- **Scroll-to-bottom reliability** — improved auto-scroll when new messages arrive.
- **Add-server dialog centering** — the add server modal is now properly centered.
- **GIF hover preview** — the GIF hover animation now displays correctly.
- **Channel handler module export** — fixed a module export issue introduced during codebase modularization.
---
## [2.9.8] — 2026-04-12
### Added
- **Read-only channels** — admins can now mark any text channel as read-only. Members without the new `Read-Only Override` role permission can still read and react, but the message input is hidden. Useful for announcement-style channels. (#5231)
- **`Read-Only Override` role permission** — grants specific roles the ability to post in read-only channels.
- **Server-relayed mic illumination** — the speaking indicator now reflects what the server actually received rather than local mic detection. If your audio isn't making it to the server, the indicator won't light up, giving a more accurate picture of what others are hearing.
- **Role display picker** — new setting to choose between "Colored Name" (role color applied to the username) or "Dot" (small colored circle next to the name). Applies to both chat messages and the member list.
- **Welcome message** — admins can configure a custom welcome message shown when a user joins a channel. Use `{user}` as a placeholder for the username. Set via Admin Settings; leave blank to disable.
- **Masked link warning** — clicking a markdown link where the display text differs from the URL now shows a confirmation dialog with the real destination before navigating. Helps prevent phishing via disguised links.
- **Admin password reset via `.env`** — set `ADMIN_RESET_PASSWORD=<newpass>` in `.env` and restart. The admin password is updated, any ban/mute on the admin account is cleared, and the variable is automatically removed from `.env` after use.
- **Crash log** — uncaught exceptions, unhandled rejections, and non-zero exits are now written to `crash.log` in the data directory with timestamps and memory stats, surviving even when stdout isn't captured.
- **Event loop lag monitor** — logs a warning when the Node.js event loop is blocked for more than 500 ms, helping diagnose freezes on low-power hardware like Raspberry Pi.
### Changed
- **Role permission row highlight** — checking a permission in the role editor now lights up that entire row with an accent background, making it easier to see which permissions are enabled at a glance.
- **Dynamic memory watchdog threshold** — the memory warning threshold now auto-detects system RAM instead of using a hardcoded 350 MB limit, so Raspberry Pi and other low-memory hosts get appropriate warnings.
### Fixed
- **E2E pinned message decryption** — pinned messages in encrypted DMs are now decrypted before rendering in the pinned panel.
- **Pinned panel stale data** — switching channels now auto-closes the pinned panel so stale pins from the previous channel don't linger.
- **User deletion FK constraint errors** — deleting a user (admin purge or self-delete) now nullifies all non-cascading foreign key references before removing the user row, preventing SQLITE_CONSTRAINT failures.
- **User deletion audit trail** — the `deleted_users` audit record is now inserted inside the same transaction as the purge, so it rolls back cleanly if any step fails.
- **Desktop shortcut recording** — fixed several issues: global hotkey no longer swallows the keystroke while recording a new shortcut, config state updates correctly after setting or clearing a shortcut, and duplicate listener attachment is prevented.
---
## [2.9.7] — 2026-04-09
### Changed

View file

@ -666,6 +666,93 @@ Click the **🔐** button in the DM header to view your **safety number** — a
---
## 🤖 Bot & Webhook Developer Guide
Haven has a built-in bot API powered by webhooks. Bots can send messages, delete messages, play soundboard sounds, and register custom slash commands.
### Creating a Bot
1. Go to **Settings → Server Admin Settings → Bots** (or open a channel's settings and look for the webhook/bot option)
2. Create a new webhook — give it a name, optionally set an avatar URL and a callback URL
3. Copy the **Webhook Token** (64-character hex string) — this is your bot's API key
### Sending Messages
```
POST https://your-server.com/api/webhooks/<token>
Content-Type: application/json
{
"content": "Hello from my bot!",
"username": "MyBot",
"avatar_url": "https://example.com/avatar.png"
}
```
- `content` (required) — message text, max 4000 characters
- `username` (optional) — override the bot's display name for this message
- `avatar_url` (optional) — override the bot's avatar for this message
Response: `{ "success": true, "message_id": 123 }`
### Deleting Messages
```
DELETE https://your-server.com/api/webhooks/<token>/messages/<message_id>
```
Bots can delete any message in their assigned channel. Returns `{ "success": true }`.
### Playing Soundboard Sounds
```
POST https://your-server.com/api/webhooks/<token>/sounds
Content-Type: application/json
{
"sound": "AOL - You've Got Mail"
}
```
Plays the named sound for all users currently viewing the bot's channel. Use `GET /api/sounds` (with a Bearer token) to list available sound names.
### Registering Slash Commands
Bots with a `callback_url` can register custom slash commands that users can invoke from chat:
**Register:**
```
POST https://your-server.com/api/webhooks/<token>/commands
Content-Type: application/json
{
"command": "leaderboard",
"description": "Show the current leaderboard"
}
```
**List:**
```
GET https://your-server.com/api/webhooks/<token>/commands
```
**Unregister:**
```
DELETE https://your-server.com/api/webhooks/<token>/commands/leaderboard
```
When a user types `/leaderboard`, Haven sends a POST to your bot's callback URL with the command details, signed with HMAC so you can verify authenticity.
### Rate Limits
All webhook endpoints are rate-limited to **30 requests per minute** per IP.
### Callback Payloads
If your webhook has a `callback_url` and `callback_secret` configured, Haven will POST command invocations to your URL. The payload includes an HMAC signature in the `X-Haven-Signature` header that you should verify using your callback secret.
---
## 🆘 Troubleshooting
**"SSL_ERROR_RX_RECORD_TOO_LONG" or "ERR_SSL_PROTOCOL_ERROR" in browser**

View file

@ -299,6 +299,21 @@ Haven creates a `.env` config file automatically on first launch — you don't n
After editing `.env`, restart the server.
### Running Multiple Servers
You can run more than one Haven instance on the same machine. Each instance
needs its own copy of Haven, its own port, and its own data directory so the
databases don't conflict.
1. Clone or copy the Haven folder to a separate directory for each server.
2. In each copy, edit `.env` and set a unique `PORT` (e.g. `3000`, `3001`).
3. Set a unique `HAVEN_DATA_DIR` in each `.env` so each server stores its data
separately (e.g. `HAVEN_DATA_DIR=C:\HavenData\server1`).
4. Start each server independently with `Start Haven.bat` (or `start.sh`).
That's it -- each instance runs on its own port with its own database,
uploads, and settings.
---
## Slash Commands
@ -483,6 +498,34 @@ Planned features — roughly in priority order:
---
## FAQ
**Is there an iOS app?**
We'd love to build one, but we don't currently have the capability to develop a native iOS app. It's on the list, but there's no timeline. In the meantime, Haven works great as a PWA — open your server URL in Safari and tap **Add to Home Screen** for an app-like experience.
**Is there an Android app?**
Yes! [Amni-Haven Android](https://play.google.com/store/apps/details?id=com.havenapp.mobile&gl=US) is available on Google Play, built from the ground up by Amnibro.
**Is there a desktop app?**
Yes — [Haven Desktop](https://github.com/ancsemi/Haven-Desktop) is available for Windows, macOS, and Linux with features like per-app audio sharing, native notifications, and system tray support.
**Can I use Haven without self-hosting?**
Yes. You can join someone else's Haven server if they share an invite link with you. You only need to self-host if you want to run your own server.
**Is Haven end-to-end encrypted?**
Haven supports optional E2EE for direct messages (ECDH P-256 + AES-256-GCM). Channel messages are stored on your server, so your data security depends on your hosting setup.
**Can I create bots for Haven?**
Yes. Haven supports webhooks with a REST API — bots can send messages, delete messages, play soundboard sounds, register custom slash commands, and receive message callbacks with HMAC-signed payloads. Set up webhooks in **Settings → Server Admin Settings → Bots**. See the [Bot Developer Guide](GUIDE.md#-bot--webhook-developer-guide) for full API docs.
**Does Haven have moderation tools?**
Yes — role-based permissions, kick/ban/mute, slow mode, read-only announcement channels, IP banning, and a full moderation REST API for bot-driven moderation.
**How do I report a bug or request a feature?**
Open an issue on [GitHub](https://github.com/ancsemi/Haven/issues). PRs are always welcome.
---
## License
AGPL-3.0 — free to use, modify, and share. Any modified version you deploy as a network service must release its source code. See [LICENSE](LICENSE).

View file

@ -953,13 +953,13 @@
<span class="discord-feat">🖥️ Windows &amp; Linux</span>
</div>
<div style="margin-top: 28px; display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.2.0/Haven-Setup-1.2.0.exe" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/Haven-Setup-1.3.0.exe" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Windows Installer
</a>
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.2.0/Haven-1.2.0.AppImage" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/Haven-1.3.0.AppImage" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Linux AppImage
</a>
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.2.0/haven-desktop_1.2.0_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/haven-desktop_1.3.0_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Linux .deb
</a>
</div>
@ -1415,12 +1415,12 @@
</div>
<div class="download-card fade-in">
<h2>&#x2B21; Haven Server &mdash; v2.9.7</h2>
<h2>&#x2B21; Haven Server &mdash; v3.5.0</h2>
<p class="download-version">Latest stable release &middot; Windows, macOS &amp; Linux &middot; ~5 MB</p>
<div class="download-btn-group">
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v2.9.7 (.zip)
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v3.5.0 (.zip)
</a>
<div class="download-alt-links">
<a href="https://github.com/ancsemi/Haven" target="_blank">&#9965; View on GitHub</a>
@ -1437,7 +1437,43 @@
<div class="version-list">
<div class="version-list-inner">
<div class="version-item">
<div><span class="v-name">v2.9.7</span><span class="v-tag latest">Latest</span></div>
<div><span class="v-name">v3.5.0</span><span class="v-tag latest">Latest</span></div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.4.0</span> &mdash; Quote/edit UX, bot API upgrades, SSO quality fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.3.0</span> &mdash; Last read indicator, per-event volume sliders, server list sync improvements</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.3.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.2.0</span> &mdash; Mark as Read, pinned message jump, iOS Safari fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.2.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.1.1</span> &mdash; Status bar toggle, server URL display, mobile fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.1.1.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.1.0</span> &mdash; Server banners, server icon sync</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.1.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.0.0</span> &mdash; SSO registration, advanced search filters, reply notifications</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.0.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v2.9.9</span> &mdash; Encrypted server list sync, jump-to-bottom, edit-mode emoji picker</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.9.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v2.9.8</span> &mdash; Read-only channels, server-relayed mic illumination, role display picker</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.8.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v2.9.7</span> &mdash; Open-source STUN servers, STUN_URLS env var</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip">Download &rarr;</a>
</div>
<div class="version-item">

195
docs/server-list-sync.md Normal file
View file

@ -0,0 +1,195 @@
# Server List Sync — Developer Integration Guide
## What It Does
Haven now stores an encrypted copy of each user's server list on every server they log into. When a user logs in from a new device (or after clearing browser data), their full server list is automatically restored. No manual re-adding.
## Why
The server list was previously stored only in `localStorage` / client-side storage. Switching devices, clearing app data, or reinstalling meant manually re-adding every server. This was the #1 friction point for multi-server users.
## How It Works
### The Flow
1. **On login** (password entry required — not auto-login/JWT refresh):
- Client derives a wrapping key from the password using `HavenE2E.deriveWrappingKey(password)` — this already happens for E2E encryption
- Client calls `GET /api/auth/user-servers` → receives an encrypted blob (or null)
- Client decrypts the blob using AES-256-GCM with the wrapping key
- Client merges the decrypted server list with its local list (union by URL)
- If the merged list differs from what the server had, client re-encrypts and calls `PUT /api/auth/user-servers`
2. **On adding/removing a server:**
- Client updates local storage as before
- Client re-encrypts the full list and pushes to the current server via `PUT /api/auth/user-servers`
3. **On password change:**
- Client re-encrypts the server list blob with the new password-derived key (same as E2E key re-wrapping)
### Multi-Device Convergence
The server list converges across devices passively:
- DeviceA adds ServerD → pushes to ServerA
- DeviceB logs into ServerA → pulls the updated list → now has ServerD
- DeviceB visits ServerB → pushes the merged list → ServerB is updated too
- No server-to-server communication ever occurs
### Removal Handling
Removals are **local-only**. When a user removes a server:
- The URL is added to a local `haven_servers_removed` set (stored in localStorage / app storage)
- The server is removed from the local list
- The updated (shorter) list is pushed to the server
- Remote blobs on other servers may still contain the removed URL — but the local removed-set prevents it from reappearing after merge
---
## API Endpoints
Both endpoints are on the auth router (`/api/auth/`), protected by JWT.
### `GET /api/auth/user-servers`
**Headers:** `Authorization: Bearer <jwt>`
**Response:**
```json
{ "blob": "<base64-encoded-encrypted-string>" }
```
or
```json
{ "blob": null }
```
### `PUT /api/auth/user-servers`
**Headers:** `Authorization: Bearer <jwt>`, `Content-Type: application/json`
**Body:**
```json
{ "blob": "<base64-encoded-encrypted-string>" }
```
**Constraints:** Blob must be a string, max 65536 characters.
**Response:**
```json
{ "ok": true }
```
---
## Encryption Format
### Key Derivation
The wrapping key is the same one used for E2E DM encryption:
```
password (plaintext)
→ PBKDF2(SHA-256, salt="haven-e2e-wrapping-v3", iterations=210000)
→ 256 bits
→ hex string (64 chars)
```
This hex string is what `HavenE2E.deriveWrappingKey(password)` returns. The Android app likely already computes this for E2E — reuse it.
### Blob Encryption
The blob stored on the server is: `base64(salt + iv + ciphertext)`
```
wrappingHex (64-char hex string)
→ convert to 32 raw bytes
→ PBKDF2(SHA-256, salt=<random 16 bytes>, iterations=100000)
→ AES-256-GCM key
plaintext = JSON.stringify(serverList)
iv = 12 random bytes
ciphertext = AES-GCM-encrypt(key, iv, plaintext)
blob = base64(salt[16] + iv[12] + ciphertext[...])
```
### Blob Decryption
```
raw = base64decode(blob)
salt = raw[0..15] (16 bytes)
iv = raw[16..27] (12 bytes)
ct = raw[28..] (remaining)
key = PBKDF2(SHA-256, wrappingHexBytes, salt, 100000) → AES-256-GCM key
plaintext = AES-GCM-decrypt(key, iv, ct)
serverList = JSON.parse(plaintext)
```
### Plaintext Format
The decrypted JSON is an array of server objects:
```json
[
{
"url": "https://haven.example.com",
"name": "My Server",
"icon": "https://haven.example.com/uploads/icon.png",
"addedAt": 1712937600000
}
]
```
Only `url` is required. `name`, `icon`, and `addedAt` are optional metadata.
---
## Merge Logic
The merge is a **union by URL**:
```
localUrls = set of URLs from local storage
remoteUrls = set of URLs from decrypted blob
removedUrls = set of URLs the user has explicitly removed (local-only)
for each remote server:
if URL not in localUrls AND URL not in removedUrls:
add to local list
if merged list != remote list:
re-encrypt and push
```
This is commutative and idempotent — order of operations doesn't matter, and running it twice produces the same result.
---
## Integration Checklist for Android
1. **Compute the wrapping key** from the password at login (you probably already do this for E2E):
```
PBKDF2(SHA-256, password, "haven-e2e-wrapping-v3", 210000) → 32 bytes → hex
```
2. **After login**, call `GET /api/auth/user-servers` with the JWT
3. **If blob is non-null**, decrypt it using the format above
4. **Merge** with the app's local server list (union by URL, excluding removed servers)
5. **If changed**, re-encrypt and `PUT /api/auth/user-servers`
6. **On add/remove server**, re-encrypt the full list and push
7. **On password change**, re-encrypt with the new wrapping key and push
8. **Store removed-server URLs locally** (app preferences / local DB) so they don't reappear from stale blobs
---
## Security Notes
- The server admin **cannot read** the server list — it's encrypted with the user's password
- AES-GCM is authenticated — tampered blobs fail decryption silently (client falls back to local list)
- No server-to-server communication exists — servers are completely unaware of each other
- The wrapping key never leaves the client device

33
donor-order.json Normal file
View file

@ -0,0 +1,33 @@
{
"sponsors": [
"Amnibro",
"MutantRabbbit767",
"c0urier",
"BillyAlt",
"infracritical",
"nexitem"
],
"donors": [
"Andrew Schott",
"birdycrazy",
"Taylan",
"(,,•ᴗ•,,)",
"Morgan",
"deNully",
"HoppyGamers",
"khyrna",
"wreckedcarzz",
"c0urier",
"Ezmana",
"john doe",
"JollyOrc",
"lataxd9",
"ohmygdala",
"Orange Lantern",
"MutantRabbbit767",
"CloneBtw",
"haruna",
"ArtyDaSmarty",
"6yBbBc"
]
}

View file

@ -25,6 +25,9 @@
"Orange Lantern",
"lataxd9",
"HoppyGamers",
"deNully"
"deNully",
"Morgan",
"Taylan",
"birdycrazy"
]
}

View file

@ -1,6 +1,6 @@
{
"name": "haven",
"version": "2.9.7",
"version": "3.5.0",
"description": "Haven — self-hosted private chat for your server, your rules",
"license": "AGPL-3.0",
"main": "server.js",

View file

@ -10,8 +10,8 @@
<link rel="manifest" href="/manifest.webmanifest">
<title>Haven</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cG9seWdvbiBwb2ludHM9IjUwLDMgOTMsMjggOTMsNzIgNTAsOTcgNyw3MiA3LDI4IiBmaWxsPSJub25lIiBzdHJva2U9IiM2YjRmZGIiIHN0cm9rZS13aWR0aD0iOCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjx0ZXh0IHg9IjUwIiB5PSI2MyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9IkFyaWFsLEhlbHZldGljYSxzYW5zLXNlcmlmIiBmb250LXdlaWdodD0iYm9sZCIgZm9udC1zaXplPSIzOCIgZmlsbD0iIzZiNGZkYiIgb3BhY2l0eT0iMC45NSI+SDwvdGV4dD48L3N2Zz4=">
<link rel="stylesheet" href="/css/style.css?v=2.7.11">
<script src="/js/theme-init.js"></script>
<link rel="stylesheet" href="/css/style.css?v=3.4.0">
<script src="/js/theme-init.js?v=3.4.0"></script>
</head>
<body>
@ -32,6 +32,9 @@
<button class="server-icon manage-servers" id="manage-servers-btn" data-i18n-title="app.sidebar.manage_servers" title="Manage Servers">
<span class="server-icon-text"></span>
</button>
<button class="server-icon sync-servers" id="sync-servers-btn" title="Sync Server List">
<span class="server-icon-text"></span>
</button>
</nav>
<!-- ─── Left Sidebar ────────────────────────────── -->
@ -257,6 +260,10 @@
<!-- ─── Main Content ────────────────────────────── -->
<main class="main">
<!-- Server Banner (always behind all content) -->
<div id="server-banner-display" class="server-banner-display" style="display:none">
<img id="server-banner-img" class="server-banner-img" src="" alt="Server banner">
</div>
<header class="channel-header">
<button id="mobile-menu-btn" class="mobile-menu-btn" data-i18n-title="header.mobile_menu" data-i18n-aria-label="header.mobile_menu" title="Menu" aria-label="Menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
@ -293,7 +300,7 @@
</div>
</div>
<div class="search-container" id="search-container" style="display:none">
<input type="text" id="search-input" class="search-input" data-i18n-placeholder="header.search_placeholder" placeholder="Search messages..." maxlength="100">
<input type="text" id="search-input" class="search-input" data-i18n-placeholder="header.search_placeholder" placeholder="Search... from:user in:#channel has:image" maxlength="200">
<button id="search-close-btn" class="icon-btn small" title="Close search">&times;</button>
</div>
<!-- Update available banner -->
@ -441,6 +448,11 @@
<!-- Floating "⋯" button for touch-device message actions fallback -->
<button id="msg-more-btn" aria-label="Message actions"></button>
</div>
<button id="jump-to-bottom" class="jump-to-bottom" data-i18n-title="app.actions.jump_to_bottom" title="Jump to bottom">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div class="typing-indicator" id="typing-indicator"></div>
<div id="reply-bar" class="reply-bar" style="display:none">
<span class="reply-bar-text" id="reply-preview-text"></span>
@ -462,11 +474,25 @@
<span class="upload-icon-clippy">📎</span>
</button>
<span class="input-actions-divider"></span>
<button id="emoji-btn" class="btn-emoji" data-i18n-title="app.input_bar.emoji_btn" title="Emoji">😀</button>
<button id="emoji-btn" class="btn-emoji" data-i18n-title="app.input_bar.emoji_btn" title="Emoji">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
<circle cx="9" cy="10" r="1.2" fill="currentColor" stroke="none"/>
<circle cx="15" cy="10" r="1.2" fill="currentColor" stroke="none"/>
</svg>
</button>
<span class="input-actions-divider"></span>
<button id="gif-btn" class="btn-gif" data-i18n-title="app.input_bar.gif_btn" title="Search GIFs"><span data-i18n="app.input_bar.gif_label">GIF</span></button>
<span class="input-actions-divider"></span>
<button id="poll-btn" class="btn-poll" data-i18n-title="app.input_bar.poll_btn" title="Create Poll">📊</button>
<button id="poll-btn" class="btn-poll" data-i18n-title="app.input_bar.poll_btn" title="Create Poll">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="6" y1="20" x2="6" y2="14"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="2" y1="20" x2="22" y2="20"/>
</svg>
</button>
</div>
<div id="emoji-picker" class="emoji-picker" style="display:none"></div>
<div id="gif-picker" class="gif-picker" style="display:none">
@ -611,7 +637,7 @@
</div>
<!-- ─── Status Bar (bottom) ───────────────────────── -->
<div class="status-bar" id="status-bar">
<div class="status-bar" id="status-bar" style="display:none">
<div class="status-item">
<span class="led on" id="status-server-led"></span>
<span class="label" data-i18n="status_bar.server">Server</span>
@ -634,6 +660,11 @@
<span class="value" id="status-online-count">0</span>
</div>
<span class="spacer"></span>
<div class="status-item status-url-item" id="status-url-item">
<span class="value status-url-text" id="status-url-text" title="Click to copy server address"></span>
<button class="status-url-toggle" id="status-url-toggle" title="Show/hide server address">👁</button>
</div>
<div class="divider"></div>
<div class="status-item">
<span class="value" id="status-clock"></span>
</div>
@ -642,6 +673,8 @@
<span class="value" id="status-version"></span>
</div>
</div>
<!-- Status bar toggle tab (visible when bar is hidden) -->
<button class="status-bar-toggle-tab" id="status-bar-toggle" title="Toggle status bar (debug footer)">📊</button>
<!-- Online users overlay (floats above status bar) -->
<div id="online-overlay" class="online-overlay" style="display:none">
@ -678,6 +711,37 @@
</div>
</div>
<!-- Thread Panel (slides in from right) -->
<div id="thread-panel" class="thread-panel" style="display:none">
<div class="thread-panel-resizer" id="thread-panel-resizer" aria-hidden="true"></div>
<div class="thread-panel-header">
<div class="thread-panel-header-top">
<span class="thread-panel-icon">🧵</span>
<span id="thread-panel-title" class="thread-panel-title">Thread</span>
<button id="thread-panel-pip" class="icon-btn small" title="Pop out thread (PiP)" aria-pressed="false"></button>
<button id="thread-panel-close" class="icon-btn small">&times;</button>
</div>
<div class="thread-parent-meta" id="thread-parent-meta">
<div class="thread-parent-avatar-wrap" id="thread-parent-avatar-wrap"></div>
<span class="thread-parent-name" id="thread-parent-name">Thread starter</span>
</div>
<div class="thread-parent-preview" id="thread-parent-preview"></div>
</div>
<div class="thread-messages" id="thread-messages"></div>
<div class="thread-reply-bar" id="thread-reply-bar" style="display:none">
<span id="thread-reply-preview-text"></span>
<button id="thread-reply-close-btn" class="icon-btn small" aria-label="Cancel thread reply">&times;</button>
</div>
<div class="thread-input-area">
<textarea id="thread-input" class="thread-input" placeholder="Reply in thread..." rows="1"></textarea>
<button id="thread-send-btn" class="thread-send-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M22 2L11 13"/><path d="M22 2L15 22L11 13L2 9L22 2Z"/>
</svg>
</button>
</div>
</div>
<!-- First-Time Setup Wizard (Admin only) -->
<div class="modal-overlay" id="setup-wizard-modal" style="display:none">
<div class="modal modal-wizard">
@ -833,7 +897,8 @@
</div>
<div class="form-group">
<label data-i18n="modals.add_server.address_label">Server Address</label>
<input type="text" id="server-url-input" placeholder="e.g. https://192.168.1.5:3000">
<input type="text" id="server-url-input" placeholder="e.g. https://192.168.1.5:3000" list="known-servers-datalist">
<datalist id="known-servers-datalist"></datalist>
</div>
<div class="form-group">
<label data-i18n="modals.add_server.icon_label">Icon URL</label> <span class="muted-text" data-i18n="modals.add_server.icon_optional">(optional)</span>
@ -935,14 +1000,18 @@
<div class="modal modal-settings">
<div class="settings-header">
<h3>⚙️ <span data-i18n="settings.title">Settings</span></h3>
<div class="settings-tab-bar" id="settings-tab-bar">
<button class="settings-tab active" data-tab="user">👤 <span data-i18n="settings.tab.user">User</span></button>
<button class="settings-tab settings-tab-admin" data-tab="admin" style="display:none">🛡️ <span data-i18n="settings.tab.admin">Admin</span></button>
</div>
<button class="settings-close-btn" id="close-settings-btn">&times;</button>
</div>
<div class="settings-layout">
<nav class="settings-nav" id="settings-nav">
<div class="settings-nav-group" data-i18n="settings.nav.user_group">User</div>
<div class="settings-nav-item" data-target="section-language">🗣️ <span data-i18n="settings.nav.language">Language</span></div>
<div class="settings-nav-item active" data-target="section-density">📐 <span data-i18n="settings.nav.layout">Layout</span></div>
<div class="settings-nav-user">
<div class="settings-nav-item active" data-target="section-language">🗣️ <span data-i18n="settings.nav.language">Language</span></div>
<div class="settings-nav-item" data-target="section-density">📐 <span data-i18n="settings.nav.layout">Layout</span></div>
<div class="settings-nav-item" data-target="section-sounds">🔔 <span data-i18n="settings.nav.sounds">Sounds</span></div>
<div class="settings-nav-item" data-target="section-push">📲 <span data-i18n="settings.nav.push">Push</span></div>
<div class="settings-nav-item" data-target="section-password">🔒 <span data-i18n="settings.nav.password">Password</span></div>
@ -952,12 +1021,14 @@
<div class="settings-nav-item" data-target="section-plugins">🧩 <span data-i18n="settings.nav.plugins">Plugins & Themes</span></div>
<div class="settings-nav-item" data-target="section-desktop-shortcuts" id="desktop-shortcuts-nav" style="display:none">⌨️ <span data-i18n="settings.nav.shortcuts">Shortcuts</span></div>
<div class="settings-nav-item" data-target="section-desktop-app" id="desktop-app-nav" style="display:none">🖥️ <span data-i18n="settings.nav.desktop_app">Desktop App</span></div>
<div class="settings-nav-group settings-nav-admin" style="display:none" data-i18n="settings.nav.admin_group">Admin</div>
</div>
<div class="settings-nav-admin-group" style="display:none">
<div class="settings-nav-item settings-nav-admin" data-target="section-branding" style="display:none">🏠 <span data-i18n="settings.nav.branding">Branding</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-members" style="display:none">👥 <span data-i18n="settings.nav.members">Members</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-whitelist" style="display:none">🛡️ <span data-i18n="settings.nav.whitelist">Whitelist</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-invite" style="display:none">🌐 <span data-i18n="settings.nav.invite">Invite Code</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-cleanup" style="display:none">🗑️ <span data-i18n="settings.nav.cleanup">Cleanup</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-backup" style="display:none">💾 <span data-i18n="settings.nav.backup">Backup</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-uploads" style="display:none">📁 <span data-i18n="settings.nav.limits">Limits</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-sounds-admin" style="display:none">🔊 <span data-i18n="settings.nav.admin_sounds">Sounds</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-emojis" style="display:none">😎 <span data-i18n="settings.nav.emojis">Emojis</span></div>
@ -966,93 +1037,9 @@
<div class="settings-nav-item settings-nav-admin" data-target="section-bots" style="display:none">🤖 <span data-i18n="settings.nav.bots">Bots</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-import" style="display:none">📦 <span data-i18n="settings.nav.import">Import</span></div>
<div class="settings-nav-item settings-nav-admin" data-target="section-modmode" style="display:none">🔧 <span data-i18n="settings.nav.mod_mode">Mod Mode</span></div>
</div>
</nav>
<div class="settings-body">
<!-- Desktop Shortcuts Section (only shown in Haven Desktop) -->
<div class="settings-section" id="section-desktop-shortcuts" style="display:none">
<h5 class="settings-section-title">⌨️ <span data-i18n="settings.desktop_shortcuts_section.title">Desktop Shortcuts</span></h5>
<p class="settings-desc" data-i18n="settings.desktop_shortcuts_section.desc">Global keyboard shortcuts — active even when Haven is in the background. Click a row to rebind, or "Clear" to disable.</p>
<div class="shortcut-table" id="shortcut-table">
<div class="shortcut-row" id="shortcut-row-mute">
<span class="shortcut-label" data-i18n="settings.desktop_shortcuts_section.mute_unmute">Mute / Unmute</span>
<span class="shortcut-key" id="shortcut-key-mute"></span>
<div class="shortcut-btns">
<button class="btn-xs shortcut-record-btn" data-action="mute" data-i18n="settings.desktop_shortcuts_section.change_btn">Change</button>
<button class="btn-xs shortcut-clear-btn" data-action="mute" data-i18n="settings.desktop_shortcuts_section.clear_btn">Clear</button>
</div>
</div>
<div class="shortcut-row" id="shortcut-row-deafen">
<span class="shortcut-label" data-i18n="settings.desktop_shortcuts_section.deafen_undeafen">Deafen / Undeafen</span>
<span class="shortcut-key" id="shortcut-key-deafen"></span>
<div class="shortcut-btns">
<button class="btn-xs shortcut-record-btn" data-action="deafen" data-i18n="settings.desktop_shortcuts_section.change_btn">Change</button>
<button class="btn-xs shortcut-clear-btn" data-action="deafen" data-i18n="settings.desktop_shortcuts_section.clear_btn">Clear</button>
</div>
</div>
<div class="shortcut-row" id="shortcut-row-ptt">
<span class="shortcut-label"><span data-i18n="settings.desktop_shortcuts_section.ptt">Push-to-Talk</span> <span class="muted-text" style="font-weight:400;font-size:0.85em" data-i18n="settings.desktop_shortcuts_section.ptt_toggle">(toggle)</span></span>
<span class="shortcut-key" id="shortcut-key-ptt" data-i18n="settings.desktop_shortcuts_section.not_set">Not set</span>
<div class="shortcut-btns">
<button class="btn-xs shortcut-record-btn" data-action="ptt" data-i18n="settings.desktop_shortcuts_section.change_btn">Change</button>
<button class="btn-xs shortcut-clear-btn" data-action="ptt" data-i18n="settings.desktop_shortcuts_section.clear_btn">Clear</button>
</div>
</div>
</div>
<p class="settings-hint" id="shortcut-status" style="margin-top:8px"></p>
</div>
<!-- Layout Density Section -->
<!-- Desktop App Settings Section (only shown in Haven Desktop) -->
<div class="settings-section" id="section-desktop-app" style="display:none">
<h5 class="settings-section-title">🖥️ <span data-i18n="settings.desktop_app_section.title">Desktop App</span></h5>
<p class="settings-desc" data-i18n="settings.desktop_app_section.desc">Settings specific to Haven Desktop.</p>
<div class="desktop-pref-row">
<label class="toggle-row">
<input type="checkbox" id="pref-start-on-login">
<span class="toggle-label" data-i18n="settings.desktop_app_section.start_on_login">Start on Login</span>
</label>
<p class="settings-hint" data-i18n="settings.desktop_app_section.start_on_login_hint">Launch Haven Desktop automatically when you log in to your computer.</p>
</div>
<div class="desktop-pref-row" id="pref-start-hidden-row" style="display:none">
<label class="toggle-row">
<input type="checkbox" id="pref-start-hidden">
<span class="toggle-label" data-i18n="settings.desktop_app_section.start_hidden">Start Hidden to Tray</span>
</label>
<p class="settings-hint" data-i18n="settings.desktop_app_section.start_hidden_hint">When launching on login, start minimized to the system tray instead of showing the window.</p>
</div>
<div class="desktop-pref-row">
<label class="toggle-row">
<input type="checkbox" id="pref-minimize-to-tray">
<span class="toggle-label" data-i18n="settings.desktop_app_section.minimize_to_tray">Minimize to Tray on Close</span>
</label>
<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">
<span class="toggle-label" data-i18n="settings.desktop_app_section.force_sdr">Force SDR (sRGB) Color</span>
</label>
<p class="settings-hint" data-i18n="settings.desktop_app_section.force_sdr_hint">Fix washed-out or over-saturated colors on HDR monitors. Requires restart.</p>
</div>
<div class="desktop-pref-row" style="margin-top:16px;padding-top:12px;border-top:1px solid var(--border)">
<span class="settings-hint" id="desktop-version-info" style="opacity:0.6"></span>
</div>
</div>
<div class="settings-body" id="settings-body-user">
<!-- Language Section -->
<div class="settings-section" id="section-language">
@ -1135,6 +1122,57 @@
</div>
</div>
<!-- Role Color Display -->
<div class="settings-section" id="section-role-display" style="border-top: 1px solid var(--border-light); padding-top: 16px; margin-top: 8px;">
<h5 class="settings-section-title">🎨 Role Display</h5>
<div class="density-picker" id="role-display-picker">
<button type="button" class="density-btn active" data-roledisplay="colored-name" title="Color the username with the role color">
<span class="density-icon" style="color:#5865f2;font-weight:bold">A</span>
<span class="density-label">Colored Name</span>
</button>
<button type="button" class="density-btn" data-roledisplay="dot" title="Show a colored dot next to the name (legacy)">
<span class="density-icon"></span>
<span class="density-label">Dot</span>
</button>
</div>
<small class="settings-hint" style="margin-top:4px;display:block">How role colors are shown next to usernames in chat and the member list.</small>
</div>
<!-- Toolbar Icon Style -->
<div class="settings-section" id="section-toolbar-icons" style="border-top: 1px solid var(--border-light); padding-top: 16px; margin-top: 8px;">
<h5 class="settings-section-title">🎛️ Toolbar Icons</h5>
<div class="density-picker" id="toolbar-icon-picker">
<button type="button" class="density-btn active" data-toolbaricons="mono" title="Use sleek monochrome icons in message toolbars">
<span class="density-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle>
<path d="M8.5 14.5c1 1.2 2.2 1.8 3.5 1.8s2.5-.6 3.5-1.8" stroke-linecap="round"></path>
<circle cx="9.2" cy="10.2" r="1" fill="currentColor" stroke="none"></circle>
<circle cx="14.8" cy="10.2" r="1" fill="currentColor" stroke="none"></circle>
</svg>
</span>
<span class="density-label">Monochrome</span>
</button>
<button type="button" class="density-btn" data-toolbaricons="emoji" title="Use colorful emoji icons in message toolbars">
<span class="density-icon">😀</span>
<span class="density-label">Colorful Emoji</span>
</button>
</div>
<label class="toolbar-slots-row" for="toolbar-visible-slots">
<span>Visible toolbar slots before overflow</span>
<input type="range" id="toolbar-visible-slots" min="1" max="7" step="1" value="3">
<span id="toolbar-visible-slots-value" class="toolbar-slots-value">3</span>
</label>
<div class="toolbar-order-wrap">
<div class="toolbar-order-head">
<span>Toolbar slot order</span>
<button type="button" class="btn-sm" id="toolbar-order-reset-btn">Reset</button>
</div>
<div id="toolbar-order-list" class="toolbar-order-list"></div>
</div>
<small class="settings-hint" style="margin-top:4px;display:block">Applies to message and thread hover action bars.</small>
</div>
<!-- Image Display Mode -->
<div class="settings-section" id="section-density" style="border-top: 1px solid var(--border-light); padding-top: 16px; margin-top: 8px;">
<h5 class="settings-section-title">🖼️ <span data-i18n="settings.image_display.title">Image Display</span></h5>
@ -1150,13 +1188,71 @@
</div>
</div>
<!-- Banner Display (client-side) -->
<div class="settings-section" id="section-banner-display" style="border-top: 1px solid var(--border-light); padding-top: 16px; margin-top: 8px; display:none;">
<h5 class="settings-section-title">🏞️ Banner Display</h5>
<label style="margin-top:4px;display:flex;align-items:center;gap:8px;font-size:12px">
<span style="white-space:nowrap">Banner height</span>
<input type="range" id="banner-height-slider" min="80" max="400" step="10" value="180" style="flex:1">
<span id="banner-height-value" style="min-width:36px;text-align:right;font-variant-numeric:tabular-nums">180px</span>
</label>
<label style="margin-top:4px;display:flex;align-items:center;gap:8px;font-size:12px">
<span style="white-space:nowrap">Vertical offset</span>
<input type="range" id="banner-offset-slider" min="0" max="100" step="1" value="0" style="flex:1">
<span id="banner-offset-value" style="min-width:30px;text-align:right;font-variant-numeric:tabular-nums">0%</span>
</label>
<label class="select-row" style="margin-top:8px">
<span>Header style</span>
<select id="banner-header-mode" style="max-width:160px">
<option value="full">Full Header (opaque)</option>
<option value="shaded">Shaded Header</option>
<option value="minimal">Minimal Header</option>
<option value="transparent">Transparent Header</option>
</select>
</label>
<small class="settings-hint">How the banner interacts with the channel header area. These settings are per-device.</small>
</div>
<!-- Chat Behavior -->
<div class="settings-section" id="section-chat-behavior" style="border-top: 1px solid var(--border-light); padding-top: 16px; margin-top: 8px;">
<h5 class="settings-section-title">💬 Chat</h5>
<label class="toggle-row">
<span>Up Arrow Edits Last Message</span>
<input type="checkbox" id="up-arrow-edit" checked>
</label>
<small class="settings-hint" style="display:block;margin-bottom:6px">Press the Up arrow key on an empty input to quickly edit your last message.</small>
</div>
<!-- Status Bar -->
<div class="settings-section" id="section-statusbar" style="border-top: 1px solid var(--border-light); padding-top: 16px; margin-top: 8px;">
<h5 class="settings-section-title">📊 Status Bar</h5>
<label class="toggle-row">
<span>Show Status Bar</span>
<input type="checkbox" id="show-status-bar">
</label>
<small class="settings-hint" style="display:block;margin-bottom:6px">Show the status bar at the bottom of the screen with server, ping, channel, and online info.</small>
</div>
<!-- Sounds Section -->
<div class="settings-section" id="section-sounds">
<h5 class="settings-section-title">🔔 <span data-i18n="settings.sounds_section.title">Sounds</span></h5>
<div class="notif-settings">
<label class="toggle-row">
<span data-i18n="settings.sounds_section.notifications">Notifications</span>
<input type="checkbox" id="notif-enabled" checked>
<input type="checkbox" id="notif-enabled">
</label>
<small class="settings-hint" style="display:block;margin-bottom:6px">General message sounds (off by default). @Mentions, replies, and DMs always notify.</small>
<label class="toggle-row">
<span>@Mentions</span>
<input type="checkbox" id="notif-mentions-enabled" checked>
</label>
<label class="toggle-row">
<span>Replies</span>
<input type="checkbox" id="notif-replies-enabled" checked>
</label>
<label class="toggle-row">
<span>Direct Messages</span>
<input type="checkbox" id="notif-dm-enabled" checked>
</label>
<label class="toggle-row">
<span data-i18n="settings.sounds_section.auto_accept_streams">Auto-accept Streams</span>
@ -1220,6 +1316,28 @@
</select>
</label>
<div class="notif-divider"></div>
<label class="volume-row">
<span data-i18n="settings.sounds_section.reply_vol">Reply Vol</span>
<input type="range" id="notif-reply-volume" min="0" max="100" value="80" class="slider-sm">
</label>
<label class="select-row">
<span data-i18n="settings.sounds_section.replies">Replies</span>
<select id="notif-reply-sound">
<option value="chime">Chime</option>
<option value="bell">Bell</option>
<option value="alert">Alert</option>
<option value="chord">Chord</option>
<option value="ping">Ping</option>
<option value="blip">Blip</option>
<option value="drop">Drop</option>
<option value="none">None</option>
</select>
</label>
<div class="notif-divider"></div>
<label class="volume-row">
<span data-i18n="settings.sounds_section.join_vol">Join Vol</span>
<input type="range" id="notif-join-volume" min="0" max="100" value="80" class="slider-sm">
</label>
<label class="select-row">
<span data-i18n="settings.sounds_section.user_joined">User Joined</span>
<select id="notif-join-sound">
@ -1231,6 +1349,11 @@
<option value="none">None</option>
</select>
</label>
<div class="notif-divider"></div>
<label class="volume-row">
<span data-i18n="settings.sounds_section.leave_vol">Leave Vol</span>
<input type="range" id="notif-leave-volume" min="0" max="100" value="80" class="slider-sm">
</label>
<label class="select-row">
<span data-i18n="settings.sounds_section.user_left">User Left</span>
<select id="notif-leave-sound">
@ -1383,62 +1506,183 @@
<div id="theme-list" class="plugin-list"></div>
</div>
<!-- Desktop Shortcuts Section (only shown in Haven Desktop) -->
<div class="settings-section" id="section-desktop-shortcuts" style="display:none">
<h5 class="settings-section-title">⌨️ <span data-i18n="settings.desktop_shortcuts_section.title">Desktop Shortcuts</span></h5>
<p class="settings-desc" data-i18n="settings.desktop_shortcuts_section.desc">Global keyboard shortcuts — active even when Haven is in the background. Click a row to rebind, or "Clear" to disable.</p>
<div class="shortcut-table" id="shortcut-table">
<div class="shortcut-row" id="shortcut-row-mute">
<span class="shortcut-label" data-i18n="settings.desktop_shortcuts_section.mute_unmute">Mute / Unmute</span>
<span class="shortcut-key" id="shortcut-key-mute"></span>
<div class="shortcut-btns">
<button class="btn-xs shortcut-record-btn" data-action="mute" data-i18n="settings.desktop_shortcuts_section.change_btn">Change</button>
<button class="btn-xs shortcut-clear-btn" data-action="mute" data-i18n="settings.desktop_shortcuts_section.clear_btn">Clear</button>
</div>
</div>
<div class="shortcut-row" id="shortcut-row-deafen">
<span class="shortcut-label" data-i18n="settings.desktop_shortcuts_section.deafen_undeafen">Deafen / Undeafen</span>
<span class="shortcut-key" id="shortcut-key-deafen"></span>
<div class="shortcut-btns">
<button class="btn-xs shortcut-record-btn" data-action="deafen" data-i18n="settings.desktop_shortcuts_section.change_btn">Change</button>
<button class="btn-xs shortcut-clear-btn" data-action="deafen" data-i18n="settings.desktop_shortcuts_section.clear_btn">Clear</button>
</div>
</div>
<div class="shortcut-row" id="shortcut-row-ptt">
<span class="shortcut-label"><span data-i18n="settings.desktop_shortcuts_section.ptt">Push-to-Talk</span> <span class="muted-text" style="font-weight:400;font-size:0.85em" data-i18n="settings.desktop_shortcuts_section.ptt_toggle">(toggle)</span></span>
<span class="shortcut-key" id="shortcut-key-ptt" data-i18n="settings.desktop_shortcuts_section.not_set">Not set</span>
<div class="shortcut-btns">
<button class="btn-xs shortcut-record-btn" data-action="ptt" data-i18n="settings.desktop_shortcuts_section.change_btn">Change</button>
<button class="btn-xs shortcut-clear-btn" data-action="ptt" data-i18n="settings.desktop_shortcuts_section.clear_btn">Clear</button>
</div>
</div>
</div>
<p class="settings-hint" id="shortcut-status" style="margin-top:8px"></p>
</div>
<!-- Desktop App Settings Section (only shown in Haven Desktop) -->
<div class="settings-section" id="section-desktop-app" style="display:none">
<h5 class="settings-section-title">🖥️ <span data-i18n="settings.desktop_app_section.title">Desktop App</span></h5>
<p class="settings-desc" data-i18n="settings.desktop_app_section.desc">Settings specific to Haven Desktop.</p>
<div class="desktop-pref-row">
<label class="toggle-row">
<input type="checkbox" id="pref-start-on-login">
<span class="toggle-label" data-i18n="settings.desktop_app_section.start_on_login">Start on Login</span>
</label>
<p class="settings-hint" data-i18n="settings.desktop_app_section.start_on_login_hint">Launch Haven Desktop automatically when you log in to your computer.</p>
</div>
<div class="desktop-pref-row" id="pref-start-hidden-row" style="display:none">
<label class="toggle-row">
<input type="checkbox" id="pref-start-hidden">
<span class="toggle-label" data-i18n="settings.desktop_app_section.start_hidden">Start Hidden to Tray</span>
</label>
<p class="settings-hint" data-i18n="settings.desktop_app_section.start_hidden_hint">When launching on login, start minimized to the system tray instead of showing the window.</p>
</div>
<div class="desktop-pref-row">
<label class="toggle-row">
<input type="checkbox" id="pref-minimize-to-tray">
<span class="toggle-label" data-i18n="settings.desktop_app_section.minimize_to_tray">Minimize to Tray on Close</span>
</label>
<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">
<span class="toggle-label" data-i18n="settings.desktop_app_section.force_sdr">Force SDR (sRGB) Color</span>
</label>
<p class="settings-hint" data-i18n="settings.desktop_app_section.force_sdr_hint">Fix washed-out or over-saturated colors on HDR monitors. Requires restart.</p>
</div>
<div class="desktop-pref-row" style="margin-top:16px;padding-top:12px;border-top:1px solid var(--border)">
<span class="settings-hint" id="desktop-version-info" style="opacity:0.6"></span>
</div>
</div>
</div><!-- /settings-body-user -->
<!-- Admin settings body (separate tab) -->
<div class="settings-body" id="settings-body-admin" style="display:none">
<!-- Admin Section (hidden for non-admins) -->
<div class="settings-section admin-settings-section" id="admin-mod-panel" style="display:none">
<h5 class="settings-section-title">🛡️ <span data-i18n="settings.admin.title">Admin</span></h5>
<!-- Server Branding -->
<div class="admin-settings" id="section-branding">
<h5 class="settings-section-subtitle">🏠 <span data-i18n="settings.admin.branding_title">Server Branding</span></h5>
<label class="select-row">
<span data-i18n="settings.admin.server_name">Server Name</span>
<input type="text" id="server-name-input" maxlength="32" class="settings-text-input" style="max-width:160px" placeholder="HAVEN">
</label>
<label class="select-row">
<span data-i18n="settings.admin.login_title">Login Title</span>
<input type="text" id="server-title-input" maxlength="40" class="settings-text-input" style="max-width:200px" placeholder="(shown on login page)">
</label>
<small class="settings-hint" style="margin-top:2px;display:block" data-i18n="settings.admin.login_title_hint">Custom title displayed on the login screen below the HAVEN logo.</small>
<div class="server-icon-upload-area">
<div class="server-icon-preview" id="server-icon-preview">
<span class="server-icon-text"></span>
</div>
<div class="server-icon-upload-controls">
<small class="settings-hint" data-i18n="settings.admin.server_icon_hint">Server icon (square, max 2 MB)</small>
<input type="file" id="server-icon-file" accept="image/*" style="font-size:11px;max-width:160px">
<div style="display:flex;gap:4px;margin-top:4px">
<button class="btn-sm btn-accent" id="server-icon-upload-btn" data-i18n="settings.admin.upload_btn">Upload</button>
<button class="btn-sm" id="server-icon-remove-btn" data-i18n="settings.admin.remove_btn">Remove</button>
<!-- Server Icon -->
<div class="settings-group" style="margin-top:12px">
<small class="settings-group-label" data-i18n="settings.admin.server_icon_hint">Server icon (square, max 2 MB)</small>
<div class="server-icon-upload-area">
<div class="server-icon-preview" id="server-icon-preview">
<span class="server-icon-text"></span>
</div>
<div class="server-icon-upload-controls">
<input type="file" id="server-icon-file" accept="image/*" style="font-size:11px;max-width:160px">
<div style="display:flex;gap:4px;margin-top:4px">
<button class="btn-sm btn-accent" id="server-icon-upload-btn" data-i18n="settings.admin.upload_btn">Upload</button>
<button class="btn-sm" id="server-icon-remove-btn" data-i18n="settings.admin.remove_btn">Remove</button>
</div>
</div>
</div>
</div>
<label class="select-row" style="margin-top:8px">
<span data-i18n="settings.admin.default_theme">Default Theme</span>
<select id="default-theme-select" style="max-width:160px">
<option value="" data-i18n="settings.admin.no_theme">None (user's choice)</option>
<option value="haven">Haven</option>
<option value="discord">Discord</option>
<option value="matrix">Matrix</option>
<option value="fallout">Fallout</option>
<option value="ffx">Final Fantasy</option>
<option value="ice">Ice</option>
<option value="nord">Nord</option>
<option value="darksouls">Dark Souls</option>
<option value="eldenring">Elden Ring</option>
<option value="bloodborne">Bloodborne</option>
<option value="cyberpunk">Cyberpunk</option>
<option value="lotr">Lord of the Rings</option>
<option value="abyss">Abyss</option>
<option value="scripture">Scripture</option>
<option value="chapel">Chapel</option>
<option value="gospel">Gospel</option>
<option value="tron">Tron</option>
<option value="halo">Halo</option>
<option value="dracula">Dracula</option>
<option value="win95">Windows 95</option>
</select>
</label>
<small class="settings-hint" style="margin-top:2px;display:block" data-i18n="settings.admin.default_theme_hint">New users see this theme on first visit. They can still pick their own.</small>
<!-- Server Banner -->
<div class="settings-group" style="margin-top:12px">
<small class="settings-group-label">Server banner (wide, max 4 MB)</small>
<div class="server-banner-upload-area">
<div class="server-banner-preview" id="server-banner-preview">
<span class="muted-text" style="font-size:11px">No banner</span>
</div>
<div class="server-banner-upload-controls">
<input type="file" id="server-banner-file" accept="image/jpeg,image/png,image/gif,image/webp" style="font-size:11px;max-width:160px">
<div style="display:flex;gap:4px;margin-top:4px">
<button class="btn-sm btn-accent" id="server-banner-upload-btn">Upload</button>
<button class="btn-sm" id="server-banner-remove-btn">Remove</button>
</div>
</div>
</div>
<small class="settings-hint">Banner display settings (height, offset, header style) are in User Settings.</small>
</div>
<!-- Theme & Welcome -->
<div class="settings-group" style="margin-top:12px">
<small class="settings-group-label">Appearance &amp; Welcome</small>
<label class="select-row">
<span data-i18n="settings.admin.default_theme">Default Theme</span>
<select id="default-theme-select" style="max-width:160px">
<option value="" data-i18n="settings.admin.no_theme">None (user's choice)</option>
<option value="haven">Haven</option>
<option value="discord">Discord</option>
<option value="matrix">Matrix</option>
<option value="fallout">Fallout</option>
<option value="ffx">Final Fantasy</option>
<option value="ice">Ice</option>
<option value="nord">Nord</option>
<option value="darksouls">Dark Souls</option>
<option value="eldenring">Elden Ring</option>
<option value="bloodborne">Bloodborne</option>
<option value="cyberpunk">Cyberpunk</option>
<option value="lotr">Lord of the Rings</option>
<option value="abyss">Abyss</option>
<option value="scripture">Scripture</option>
<option value="chapel">Chapel</option>
<option value="gospel">Gospel</option>
<option value="tron">Tron</option>
<option value="halo">Halo</option>
<option value="dracula">Dracula</option>
<option value="win95">Windows 95</option>
</select>
</label>
<small class="settings-hint" data-i18n="settings.admin.default_theme_hint">New users see this theme on first visit. They can still pick their own.</small>
<label class="select-row" style="margin-top:8px">
<span>Welcome Message</span>
<input type="text" id="welcome-message-input" maxlength="500" class="settings-text-input" style="max-width:280px" placeholder="e.g. Welcome {user}!">
</label>
<small class="settings-hint">Shown when a new user joins a channel. Use <code>{user}</code> for the username. Leave blank to disable.</small>
</div>
</div>
<div class="admin-settings" id="section-members" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
@ -1489,6 +1733,17 @@
<button class="btn-sm" id="clear-server-code-btn" data-i18n="settings.admin.invite_clear_btn">Clear</button>
</div>
</div>
<div style="margin-top:12px;">
<label class="select-row">
<span>Vanity Invite Link</span>
<input type="text" id="vanity-code-input" maxlength="32" class="settings-text-input" style="max-width:160px" placeholder="my-server">
</label>
<small class="settings-hint">Custom invite slug (3-32 chars, letters, numbers, hyphens, underscores). People can join via <code>/invite/your-slug</code></small>
<div style="display:flex;gap:4px;margin-top:4px">
<button class="btn-sm btn-accent" id="vanity-code-save-btn">Save</button>
<button class="btn-sm" id="vanity-code-clear-btn">Clear</button>
</div>
</div>
</div>
<div class="admin-settings" id="section-cleanup" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">🗑️ <span data-i18n="settings.admin.cleanup_title">Auto-Cleanup</span></h5>
@ -1508,6 +1763,34 @@
<small class="settings-hint" data-i18n="settings.admin.cleanup_size_hint">Trim oldest messages when DB exceeds N MB (0 = off)</small>
<button class="btn-sm btn-full" id="run-cleanup-now-btn" style="margin-top:4px;">🧹 <span data-i18n="settings.admin.cleanup_run_btn">Run Cleanup Now</span></button>
</div>
<div class="admin-settings" id="section-backup" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">💾 <span data-i18n="settings.admin.backup_title">Server Backup</span></h5>
<small class="settings-hint" data-i18n="settings.admin.backup_hint">Download a backup of this server, or restore from a previous backup. Useful when you don't have shell access to the host machine.</small>
<div class="settings-group" style="margin-top:10px">
<small class="settings-group-label" data-i18n="settings.admin.backup_download_label">Download Backup</small>
<div class="backup-checkboxes" style="display:flex;flex-direction:column;gap:4px;margin-top:6px">
<label class="checkbox-row"><input type="checkbox" class="backup-include" value="channels" checked> <span data-i18n="settings.admin.backup_inc_channels">Channels &amp; roles</span></label>
<label class="checkbox-row"><input type="checkbox" class="backup-include" value="users" checked> <span data-i18n="settings.admin.backup_inc_users">Users (no passwords / 2FA / emails)</span></label>
<label class="checkbox-row"><input type="checkbox" class="backup-include" value="settings" checked> <span data-i18n="settings.admin.backup_inc_settings">Server settings</span></label>
<label class="checkbox-row"><input type="checkbox" class="backup-include" value="messages"> <span data-i18n="settings.admin.backup_inc_messages">Messages (chat history)</span></label>
<label class="checkbox-row"><input type="checkbox" class="backup-include" value="files"> <span data-i18n="settings.admin.backup_inc_files">Uploaded files</span></label>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
<button class="btn-sm btn-accent" id="backup-download-btn">⬇️ <span data-i18n="settings.admin.backup_download_btn">Download Backup</span></button>
<button class="btn-sm" id="backup-select-all-btn" type="button" data-i18n="settings.admin.backup_select_all">Select All</button>
<button class="btn-sm" id="backup-select-none-btn" type="button" data-i18n="settings.admin.backup_select_none">Clear</button>
</div>
<small class="settings-hint" style="margin-top:6px;display:block" data-i18n="settings.admin.backup_modes_hint">Pick what to include. Messages and uploaded files can make the backup very large; channels / users / settings alone are usually small. User passwords, 2FA secrets, and emails are never exported.</small>
</div>
<div class="settings-group" style="margin-top:14px">
<small class="settings-group-label" data-i18n="settings.admin.backup_restore_label">Restore From Backup</small>
<div style="display:flex;gap:6px;align-items:center;margin-top:4px">
<input type="file" id="backup-restore-file" accept=".zip" style="flex:1">
<button class="btn-sm" id="backup-restore-btn">⬆️ <span data-i18n="settings.admin.backup_restore_btn">Restore</span></button>
</div>
<small class="settings-hint" style="margin-top:6px;display:block;color:var(--danger,#e84a4a)" data-i18n="settings.admin.backup_restore_warning"><strong>Warning:</strong> Restoring overwrites the current server data. The server will restart automatically — your hosting setup must support auto-restart (Docker, systemd, the Haven installer service, etc.). The previous database is preserved as <code>haven.db.pre-restore</code> for one cycle.</small>
</div>
</div>
<div class="admin-settings" id="section-uploads" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<h5 class="settings-section-subtitle">📁 <span data-i18n="settings.admin.uploads_title">Uploads &amp; Limits</span></h5>
<label class="select-row">
@ -1553,6 +1836,22 @@
<button class="btn-sm btn-full btn-accent" id="open-role-editor-btn">⚙️ <span data-i18n="settings.admin.manage_roles_btn">Manage Roles</span></button>
<button class="btn-sm" id="reset-roles-btn" title="Reset all roles to deployment defaults">🔄 <span data-i18n="settings.admin.reset_roles_btn">Reset to Default</span></button>
</div>
<div class="settings-group" style="margin-top:12px">
<small class="settings-group-label">Role Icon Visibility</small>
<label class="toggle-row">
<span>Show role icons in sidebar</span>
<input type="checkbox" id="role-icon-sidebar" checked>
</label>
<label class="toggle-row">
<span>Show role icons in chat</span>
<input type="checkbox" id="role-icon-chat">
</label>
<label class="toggle-row">
<span>Show role icons after username</span>
<input type="checkbox" id="role-icon-after-name">
</label>
<small class="settings-hint">Controls where role icons appear. "After username" moves icons to the right side of the name instead of the left.</small>
</div>
<div id="roles-list-preview" style="margin-top:8px;">
</div>
</div>
@ -1603,20 +1902,20 @@
<button class="btn-sm btn-full" id="mod-mode-settings-toggle" style="margin-top:6px;">🔧 <span data-i18n="settings.admin.modmode_toggle_btn">Toggle Mod Mode</span></button>
<button class="btn-sm btn-full" id="mod-mode-reset" data-i18n="settings.admin.modmode_reset_btn" style="margin-top:6px;">↺ Reset Layout</button>
</div>
<!-- Admin save bar — changes only apply when Save is clicked -->
<div class="admin-save-bar">
<button class="btn-accent btn-admin-save" id="admin-save-btn">💾 <span data-i18n="settings.admin.save_btn">Save Settings</span></button>
<small class="settings-hint" style="text-align:center;margin-top:4px" data-i18n="settings.admin.save_hint">Changes only apply when you click Save. Close (✕) to cancel.</small>
</div>
</div>
</div><!-- /settings-body -->
</div><!-- /settings-body-admin -->
</div><!-- /settings-layout -->
<!-- Admin save bar — changes only apply when Save is clicked -->
<div class="admin-save-bar" style="display:none">
<button class="btn-accent btn-admin-save" id="admin-save-btn">💾 <span data-i18n="settings.admin.save_btn">Save Settings</span></button>
<small class="settings-hint" style="text-align:center;margin-top:4px" data-i18n="settings.admin.save_hint">Changes only apply when you click Save. Close (✕) to cancel.</small>
</div>
</div>
</div>
<!-- Discord Import Modal -->
<div class="modal-overlay" id="import-modal" style="display:none">
<div class="modal" style="max-width:560px">
<div class="modal" style="width:560px">
<h3>📦 <span data-i18n="modals.discord_import.title">Import from Discord</span></h3>
<!-- Step 1: Upload or Connect -->
@ -1765,7 +2064,7 @@
<!-- Assign Role Modal -->
<div class="modal-overlay" id="assign-role-modal" style="display:none">
<div class="modal" style="max-width:440px">
<div class="modal" style="width:440px">
<h3>👑 <span data-i18n="modals.assign_role.title">Assign Role</span></h3>
<p id="assign-role-user-label" class="muted-text"></p>
@ -1855,7 +2154,7 @@
<!-- Sound Management Modal (Full Popout) -->
<div class="modal-overlay" id="sound-modal" style="display:none">
<div class="modal" style="max-width:640px;min-height:420px">
<div class="modal" style="width:640px;min-height:420px">
<h3>🔊 <span data-i18n="modals.sound_manager.title">Sound Manager</span></h3>
<!-- Tabs -->
<div class="sound-modal-tabs">
@ -1924,7 +2223,7 @@
<!-- Emoji Management Modal -->
<div class="modal-overlay" id="emoji-modal" style="display:none">
<div class="modal" style="max-width:520px">
<div class="modal" style="width:520px">
<h3>😎 <span data-i18n="modals.emoji_mgmt.title">Emoji Management</span></h3>
<small class="settings-hint" style="display:block;margin-bottom:12px" data-i18n="settings.admin.emojis_upload_hint">Upload images for custom server emojis (max 256 KB, png/gif/webp). Name must be lowercase, no spaces.</small>
<div class="whitelist-add-row">
@ -1954,7 +2253,7 @@
<!-- Emoji Crop Modal -->
<div class="modal-overlay" id="emoji-crop-modal" style="display:none">
<div class="modal" style="max-width:320px;text-align:center">
<div class="modal" style="width:320px;text-align:center">
<h3 style="margin-bottom:6px">✂️ <span data-i18n="modals.emoji_crop.title">Crop Emoji</span></h3>
<small class="settings-hint" style="display:block;margin-bottom:10px" data-i18n="modals.emoji_crop.hint">Drag to reposition · Slider to zoom</small>
<canvas id="emoji-crop-canvas" width="256" height="256" style="border-radius:var(--radius);cursor:grab;display:block;margin:0 auto 10px;border:2px solid var(--accent);max-width:100%;touch-action:none"></canvas>
@ -2071,7 +2370,7 @@
<!-- Manage Servers Modal -->
<div class="modal-overlay" id="manage-servers-modal" style="display:none">
<div class="modal" style="max-width:480px">
<div class="modal manage-servers-modal-inner">
<h3><span data-i18n="modals.manage_servers.title">Manage Servers</span></h3>
<p class="modal-desc" data-i18n="modals.manage_servers.desc">Edit or remove your linked servers</p>
<div id="manage-servers-list" class="manage-servers-list"></div>
@ -2084,7 +2383,9 @@
<!-- Channel context menu (appears on "..." hover button) -->
<div id="channel-ctx-menu" class="channel-ctx-menu" style="display:none">
<button class="channel-ctx-item" data-action="mark-read"><span data-i18n="context_menu.channel.mark_read">Mark as Read</span></button>
<button class="channel-ctx-item" data-action="mute">🔔 <span data-i18n="context_menu.channel.mute">Mute Channel</span></button>
<button class="channel-ctx-item" data-action="copy-channel-link">🔗 <span data-i18n="context_menu.channel.copy_link">Copy Channel Link</span></button>
<button class="channel-ctx-item" data-action="join-voice">🎙️ <span data-i18n="voice.join_ctx">Join Voice</span></button>
<button class="channel-ctx-item" data-action="leave-voice" style="display:none">📴 <span data-i18n="context_menu.channel.disconnect_voice">Disconnect Voice</span></button>
<hr class="channel-ctx-sep">
@ -2118,6 +2419,10 @@
<span class="cfn-label">🖼️ <span data-i18n="channel_functions.media">Media</span></span>
<span class="cfn-badge cfn-on" data-i18n="channel_functions.on">ON</span>
</div>
<div class="cfn-row" data-fn="read-only" data-i18n-title="channel_functions.read_only_hint" title="Make this channel read-only — only users with the Read-Only Override permission can post">
<span class="cfn-label">🔒 <span data-i18n="channel_functions.read_only">Read Only</span></span>
<span class="cfn-badge cfn-off" data-i18n="channel_functions.off">OFF</span>
</div>
<div class="cfn-divider"></div>
<div class="cfn-row" data-fn="slow-mode" data-i18n-title="channel_functions.slow_mode_hint" title="Require a cooldown between messages (0 = off)">
<span class="cfn-label">🐢 <span data-i18n="channel_functions.slow_mode">Slow Mode</span></span>
@ -2162,14 +2467,16 @@
<!-- DM context menu -->
<div id="dm-ctx-menu" class="channel-ctx-menu" style="display:none">
<button class="channel-ctx-item" data-action="dm-mark-read"><span data-i18n="context_menu.dm.mark_read">Mark as Read</span></button>
<button class="channel-ctx-item" data-action="dm-mute">🔔 <span data-i18n="context_menu.dm.mute">Mute DM</span></button>
<button class="channel-ctx-item" data-action="dm-copy-link">🔗 <span data-i18n="context_menu.dm.copy_link">Copy DM Link</span></button>
<hr class="channel-ctx-sep">
<button class="channel-ctx-item danger" data-action="dm-delete">🗑️ <span data-i18n="context_menu.dm.delete">Delete DM</span></button>
</div>
<!-- Organize Sub-channels Modal -->
<div class="modal-overlay" id="organize-modal" style="display:none">
<div class="modal" style="max-width:500px">
<div class="modal" style="width:500px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<button id="organize-back-btn" class="btn-sm" style="display:none;padding:3px 8px;font-size:0.8rem" data-i18n-title="modals.organize.back_title" title="Back to channel list"><span data-i18n="modals.organize.back_btn">← Back</span></button>
<h3 id="organize-modal-title" style="margin:0">📋 <span data-i18n="modals.organize.title">Organize</span></h3>
@ -2230,7 +2537,7 @@
<!-- DM Organize Modal -->
<div class="modal-overlay" id="dm-organize-modal" style="display:none">
<div class="modal" style="max-width:460px">
<div class="modal" style="width:460px">
<h3>📋 <span data-i18n="modals.dm_organize.title">Organize DMs</span></h3>
<p class="modal-desc" data-i18n="modals.dm_organize.desc">Tag your conversations into collapsible categories</p>
@ -2266,7 +2573,7 @@
<!-- Poll Creation Modal -->
<div class="modal-overlay" id="poll-modal" style="display:none">
<div class="modal" style="max-width:440px">
<div class="modal" style="width:440px">
<h3>📊 <span data-i18n="modals.poll.title">Create Poll</span></h3>
<div class="form-group">
<label data-i18n="modals.poll.question_label">Question</label>
@ -2339,7 +2646,7 @@
</div>
<div class="modal-overlay" id="webhook-modal" style="display:none">
<div class="modal" style="max-width:520px">
<div class="modal" style="width:520px">
<h3>🤖 <span data-i18n="modals.webhook.title">Webhooks</span></h3>
<p class="modal-desc" id="webhook-modal-channel-name"></p>
@ -2372,7 +2679,7 @@
<!-- Sub-channel Subscriptions Panel -->
<div class="modal-overlay" id="sub-panel-modal" style="display:none">
<div class="modal" style="max-width:520px;max-height:80vh;display:flex;flex-direction:column">
<div class="modal" style="width:520px;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<h3 style="margin:0;flex:1">📡 <span data-i18n="modals.sub_panel.title">Sub-channel Subscriptions</span></h3>
<button class="btn-sm" id="sub-panel-close-btn" data-i18n="modals.common.close" style="font-size:1rem;padding:4px 10px">&times;</button>
@ -2445,7 +2752,7 @@
</div>
<div class="modal-overlay" id="push-error-modal" style="display:none">
<div class="modal" style="max-width:420px;text-align:center;padding:28px 24px;">
<div class="modal" style="width:420px;text-align:center;padding:28px 24px;">
<div style="font-size:48px;margin-bottom:12px;">🔕</div>
<h3 style="margin:0 0 12px;color:var(--text-primary)" data-i18n="modals.push_error.title">Push Notifications Unavailable</h3>
<p id="push-error-reason" style="color:var(--text-secondary);font-size:14px;line-height:1.5;margin:0 0 16px;"></p>
@ -2562,15 +2869,21 @@
<!-- Image Lightbox Overlay -->
<div id="image-lightbox" class="image-lightbox" style="display:none">
<button id="lightbox-prev" class="lightbox-nav lightbox-prev" aria-label="Previous image">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<img id="lightbox-img" class="lightbox-img" src="" alt="">
<button id="lightbox-next" class="lightbox-nav lightbox-next" aria-label="Next image">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>
</button>
</div>
<script src="/js/theme.js?v=2.7.3"></script>
<script src="/js/notifications.js?v=2.6.0"></script>
<script src="/js/servers.js?v=2.6.0"></script>
<script src="/js/voice.js?v=2.6.0"></script>
<script src="/js/modmode.js?v=2.6.0"></script>
<script src="/js/e2e.js?v=2.6.0"></script>
<script type="module" src="/js/app.js?v=2.7.11"></script>
<script src="/js/plugin-loader.js?v=2.6.0"></script>
<script src="/js/theme.js?v=3.4.0"></script>
<script src="/js/notifications.js?v=3.4.0"></script>
<script src="/js/servers.js?v=3.4.0"></script>
<script src="/js/voice.js?v=3.4.0"></script>
<script src="/js/modmode.js?v=3.4.0"></script>
<script src="/js/e2e.js?v=3.4.0"></script>
<script type="module" src="/js/app.js?v=3.4.0"></script>
<script src="/js/plugin-loader.js?v=3.4.0"></script>
</body>
</html>

View file

@ -62,8 +62,28 @@
/* Seek slider + time labels */
.music-seek-slider {
-webkit-appearance: none;
appearance: none;
flex: 1; min-width: 60px; max-width: 200px;
accent-color: var(--accent, #5865f2);
height: 6px;
background: transparent;
}
.music-seek-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent, #5865f2);
cursor: pointer;
margin-top: -4px;
}
.music-seek-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent, #5865f2);
cursor: pointer;
border: none;
}
.music-time {
font-size: 10px; color: var(--text-muted, #888);

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,7 @@
<div class="auth-tabs">
<button class="auth-tab active" data-tab="login" data-i18n="auth.tabs.login">Login</button>
<button class="auth-tab" data-tab="register" data-i18n="auth.tabs.register">Register</button>
<button class="auth-tab" data-tab="sso" data-i18n="auth.tabs.sso">Link Server</button>
</div>
<form id="login-form" class="auth-form">
@ -65,6 +66,46 @@
<button type="submit" class="btn-primary" data-i18n="auth.register.submit">Create Account</button>
</form>
<!-- SSO form: register using identity from another Haven server -->
<div id="sso-form" class="auth-form" style="display:none">
<p style="text-align:center;color:var(--text-muted);font-size:0.9rem;margin-bottom:16px;">
<span data-i18n="auth.sso.instruction">Register using your account from another Haven server. Your username and profile picture will be imported.</span>
</p>
<!-- Step 1: Enter home server URL -->
<div id="sso-step-server">
<div class="form-group">
<label for="sso-server-url" data-i18n="auth.sso.server_label">Your Haven server</label>
<input type="text" id="sso-server-url" list="sso-recent-servers" placeholder="haven.example.com" required>
<datalist id="sso-recent-servers"></datalist>
<small data-i18n="auth.sso.server_hint">Enter the address of the Haven server you already have an account on</small>
</div>
<button type="button" class="btn-primary" id="sso-connect-btn" data-i18n="auth.sso.connect">Connect</button>
</div>
<!-- Step 2: SSO result + set local password -->
<div id="sso-step-register" style="display:none">
<div class="sso-profile-preview" style="text-align:center;margin-bottom:16px;padding:12px;background:var(--bg-tertiary,#12122a);border-radius:8px;border:1px solid var(--border,#333)">
<div id="sso-preview-avatar" style="width:64px;height:64px;border-radius:50%;margin:0 auto 8px;background:var(--bg-secondary,#1a1a2e);overflow:hidden;display:flex;align-items:center;justify-content:center;font-size:28px;color:var(--text-muted)">?</div>
<div id="sso-preview-username" style="font-weight:600;font-size:15px;color:var(--text-primary)"></div>
<small style="color:var(--text-muted);font-size:11px" data-i18n="auth.sso.imported_from">Imported from your home server</small>
</div>
<div class="form-group">
<label for="sso-password" data-i18n="auth.sso.password_label">Set a password for this server</label>
<input type="password" id="sso-password" autocomplete="new-password" minlength="8" required>
<small data-i18n="auth.sso.password_hint">Required for encryption. Minimum 8 characters.</small>
</div>
<div class="form-group">
<label for="sso-confirm" data-i18n="auth.sso.confirm_label">Confirm Password</label>
<input type="password" id="sso-confirm" autocomplete="new-password" required>
</div>
<button type="button" class="btn-primary" id="sso-register-btn" data-i18n="auth.sso.register">Create Account</button>
<p style="text-align:center;margin-top:8px">
<a href="#" id="sso-back-btn" style="color:var(--text-muted);font-size:0.8rem;text-decoration:none" data-i18n="auth.sso.back">← Start over</a>
</p>
</div>
</div>
<div id="auth-error" class="auth-error" style="display:none"></div>
<!-- TOTP verification form (shown after login when 2FA is enabled) -->

View file

@ -5,16 +5,16 @@
// ═══════════════════════════════════════════════════════════
import SocketMethods from './modules/app-socket.js?v=2.7.9';
import UIBindMethods from './modules/app-ui.js?v=2.7.0';
import UIBindMethods from './modules/app-ui.js?v=2.7.11';
import MediaMethods from './modules/app-media.js?v=2.7.0';
import ContextMethods from './modules/app-context.js?v=2.7.3';
import ContextMethods from './modules/app-context.js?v=2.7.11';
import ChannelMethods from './modules/app-channels.js?v=2.7.8';
import MessageMethods from './modules/app-messages.js?v=2.7.10';
import MessageMethods from './modules/app-messages.js?v=2.7.11';
import UserMethods from './modules/app-users.js?v=2.7.0';
import VoiceMethods from './modules/app-voice.js?v=2.7.10';
import UtilityMethods from './modules/app-utilities.js?v=2.7.9';
import UtilityMethods from './modules/app-utilities.js?v=2.7.11';
import AdminMethods from './modules/app-admin.js?v=2.7.0';
import PlatformMethods from './modules/app-platform.js?v=2.7.8';
import PlatformMethods from './modules/app-platform.js?v=2.7.11';
class HavenApp {
constructor() {
@ -32,6 +32,9 @@ class HavenApp {
this.serverManager = new ServerManager();
this.notifications = new NotificationManager();
this.replyingTo = null; // message object being replied to
this._threadReplyingTo = null; // thread message being replied to
this._activeThreadParent = null; // currently open thread parent message ID
this._lastMoveSelectedEl = null; // last clicked message in move-selection mode
this._imageQueue = []; // queued images awaiting send
this.channelMembers = []; // for @mention autocomplete
this.mentionQuery = ''; // current partial @mention being typed
@ -82,8 +85,12 @@ class HavenApp {
{ cmd: 'wave', args: '[text]', desc: 'Wave at the chat 👋' },
{ cmd: 'play', args: '<name or url>', desc: 'Search & play music (e.g. /play Cut Your Teeth Kygo)' },
{ cmd: 'gif', args: '<query>', desc: 'Search & send a GIF inline (e.g. /gif thumbs up)' },
{ cmd: 'poll', args: '[question]', desc: 'Open the poll creator' },
];
// Load bot-registered slash commands for autocomplete
this._loadBotCommands();
// Emoji palette organized by category
this.emojiCategories = {
'Smileys': ['😀','😁','😂','🤣','😃','😄','😅','😆','😉','😊','😋','😎','😍','🥰','😘','🙂','🤗','🤩','🤔','😐','🙄','😏','😣','😥','😮','😯','😴','😛','😜','😝','😒','😔','🙃','😲','😤','😭','😢','😱','🥺','😠','😡','🤬','😈','💀','💩','🤡','👻','😺','😸','🫠','🫣','🫢','🫥','🫤','🥹','🥲','😶‍🌫️','🤭','🫡','🤫','🤥','😬','🫨','😵','😵‍💫','🥴','😮‍💨','😤','🥱','😇','🤠','🤑','🤓','😈','👿','🫶','🤧','😷','🤒','🤕','💅'],
@ -91,11 +98,11 @@ class HavenApp {
'Monkeys': ['🙈','🙉','🙊','🐵','🐒','🦍','🦧'],
'Animals': ['🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮','🐷','🐸','🐔','🐧','🐦','🦆','🦅','🦉','🐺','🐴','🦄','🐝','🦋','🐌','🐞','🐢','🐍','🐙','🐬','🐳','🦈','🐊','🦖','🦕','🐋','🦭','🦦','🦫','🦥','🐿️','🦔','🦇','🐓','🦃','🦚','🦜','🦢','🦩','🐕','🐈','🐈‍⬛'],
'Faces': ['👀','👁️','👁️‍🗨️','👅','👄','🫦','💋','🧠','🦷','🦴','👃','👂','🦻','🦶','🦵','💀','☠️','👽','🤖','🎃','😺','😸','😹','😻','😼','😽','🙀','😿','😾'],
'Food': ['🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🫐','🍒','🍑','🥭','🍍','🥝','🍅','🥑','🌽','🌶️','🫑','🥦','🧄','🧅','🥕','🍕','🍔','🍟','🌭','🍿','🧁','🍩','🍪','🍰','🎂','🧀','🥚','🥓','🥩','🍗','🌮','🌯','🫔','🥙','🍜','🍝','🍣','🍱','☕','🍺','🍷','🥤','🧊','🧋','🍵','🥂','🍾'],
'Activities':['⚽','🏀','🏈','⚾','🎾','🏐','🎱','🏓','🎮','🕹️','🎲','🧩','🎯','🎳','🎭','🎨','🎼','🎵','🎸','🥁','🎹','🏆','🥇','🏅','🎪','🎬','🎤','🎧','🎺','🪘','🎻','🪗'],
'Food': ['🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🫐','🍒','🍑','🥭','🍍','🥝','🍅','🥑','🌽','🌶️','🫑','🥦','🧄','🧅','🥕','🍕','🍔','🍟','🌭','🍿','🧁','🍩','🍪','🍰','🎂','🧀','🥚','🥓','🥩','🍗','🌮','🌯','🫔','🥙','🍜','🍝','🍣','🍱','☕','🍺','🍻','🍷','🥤','🧊','🧋','🍵','🥂','🍾','🥃','🍶','🫗','🍸','🍹'],
'Activities':['⚽','🏀','🏈','⚾','🎾','🏐','🎱','🏓','🎮','🕹️','🎲','🧩','🎯','🎳','🎭','🎨','🎼','🎵','🎶','🎸','🥁','🎹','🏆','🥇','🏅','🎪','🎬','🎤','🎧','🎺','🪘','🎻','🪗','🎉','🎊','🎈','🎀','🎗️','🏋️','🤸','🧗','🏄','🏊','🚴','⛷️','🏂','🤺'],
'Travel': ['🚗','🚕','🚀','✈️','🚁','🛸','🚢','🏠','🏢','🏰','🗼','🗽','⛩️','🌋','🏔️','🌊','🌅','🌄','🌉','🎡','🎢','🗺️','🧭','🏖️','🏕️','🌍','🌎','🌏','🛳️','⛵','🚂','🚇','🏎️','🏍️','🛵','🛶'],
'Objects': ['⌚','📱','💻','⌨️','🖥️','💾','📷','🔭','🔬','💡','🔦','📚','📝','✏️','📎','📌','🔑','🔒','🔓','🛡️','⚔️','🔧','💰','💎','📦','🎁','✉️','🔔','🪙','💸','🏷️','🔨','🪛','🧲','🧪','🧫','💊','🩺','🩹','🧬'],
'Symbols': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💝','✨','⭐','🌟','💫','🔥','💯','✅','❌','❗','❓','❕','❔','‼️','⁉️','💤','🚫','⚠️','♻️','🏳️','🏴','🎵','','','➗','💲','♾️','🔴','🟠','🟡','🟢','🔵','🟣','⚫','⚪','🟤','🔶','🔷','🔺','🔻','💠','🔘','🏳️‍🌈','🏴‍☠️','⚡','☀️','🌙','🌈','☁️','❄️','💨','🌪️','☮️','✝️','☪️','🕉️','☯️','✡️','🔯','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','⛎','🆔','⚛️','🈶','🈚','🈸','🈺','🈷️','🆚','🉐','🈹','🈲','🉑','🈴','🈳','㊗️','㊙️','🈵','🔅','🔆','🔱','📛','♻️','🔰','⭕','✳️','❇️','🔟','🔠','🔡','🔢','🔣','🔤','🆎','🆑','🆒','🆓','','🆕','🆖','🅾️','🆗','🅿️','🆘','🆙','🆚','🈁','🈂️','💱','💲','#️⃣','*️⃣','0⃣','1⃣','2⃣','3⃣','4⃣','5⃣','6⃣','7⃣','8⃣','9⃣','🔟','©️','®️','™️']
'Objects': ['⌚','📱','💻','⌨️','🖥️','💾','📷','🔭','🔬','💡','🔦','📚','📝','✏️','📎','📌','🔑','🔒','🔓','🛡️','⚔️','🔧','💰','💎','📦','🎁','✉️','🔔','🪙','💸','🏷️','🔨','🪛','🧲','🧪','🧫','💊','🩺','🩹','🧬','💬','💭','🗨️','🗯️','📣','📢','🔊','🔇','📰','🗞️','📋','📁','📂','🗂️','📅','📆','🗓️','🖊️','🖋️','✒️','📏','📐','🗑️','👑','💍','👒','🎩','🧢','👓','🕶️','🧳','🌂','☂️'],
'Symbols': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💝','✨','⭐','🌟','💫','🔥','💯','✅','❌','❗','❓','❕','❔','‼️','⁉️','!','?',',','.','💤','🚫','⚠️','♻️','🏳️','🏴','🎵','','','➗','💲','♾️','🔴','🟠','🟡','🟢','🔵','🟣','⚫','⚪','🟤','🔶','🔷','🔺','🔻','💠','🔘','🏳️‍🌈','🏴‍☠️','⚡','☀️','🌙','🌈','☁️','❄️','💨','🌪️','☮️','✝️','☪️','🕉️','☯️','✡️','🔯','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','⛎','🆔','⚛️','🈶','🈚','🈸','🈺','🈷️','🆚','🉐','🈹','🈲','🉑','🈴','🈳','㊗️','㊙️','🈵','🔅','🔆','🔱','📛','♻️','🔰','⭕','✳️','❇️','🔟','🔠','🔡','🔢','🔣','🔤','🆎','🆑','🆒','🆓','','🆕','🆖','🅾️','🆗','🅿️','🆘','🆙','🆚','🈁','🈂️','💱','💲','#️⃣','*️⃣','0⃣','1⃣','2⃣','3⃣','4⃣','5⃣','6⃣','7⃣','8⃣','9⃣','🔟','©️','®️','™️']
};
// Flat list for quick access (used by search)
@ -106,16 +113,19 @@ class HavenApp {
'😀':'grinning happy','😁':'beaming grin','😂':'joy tears laughing lol','🤣':'rofl rolling laughing','😃':'smiley happy','😄':'smile happy','😅':'sweat nervous','😆':'laughing satisfied','😉':'wink','😊':'blush happy shy','😋':'yummy delicious','😎':'cool sunglasses','😍':'heart eyes love','🥰':'loving smiling hearts','😘':'kiss blowing','🙂':'slight smile','🤗':'hug hugging open hands','🤩':'starstruck star eyes','🤔':'thinking hmm','😐':'neutral expressionless','🙄':'eye roll','😏':'smirk','😣':'persevere','😥':'sad relieved disappointed','😮':'open mouth wow surprised','😯':'hushed surprised','😴':'sleeping zzz','😛':'tongue playful','😜':'wink tongue crazy','😝':'squinting tongue','😒':'unamused','😔':'pensive sad','🙃':'upside down','😲':'astonished shocked','😤':'triumph huff angry steam','😭':'crying sob loudly','😢':'cry sad tear','😱':'scream fear horrified','🥺':'pleading puppy eyes please','😠':'angry mad','😡':'rage pouting furious','🤬':'cursing swearing angry','😈':'devil smiling imp','💀':'skull dead','💩':'poop poo','🤡':'clown','👻':'ghost boo','😺':'cat smile','😸':'cat grin','🫠':'melting face','🫣':'peeking eye','🫢':'hand over mouth','🫥':'dotted line face','🫤':'diagonal mouth','🥹':'holding back tears','🥲':'smile tear','😶‍🌫️':'face in clouds','🤭':'giggling hand over mouth','🫡':'salute','🤫':'shush quiet secret','🤥':'lying pinocchio','😬':'grimace awkward','🫨':'shaking face','😵':'dizzy','😵‍💫':'face spiral eyes','🥴':'woozy drunk','😮‍💨':'exhale sigh relief','🥱':'yawn tired boring','😇':'angel innocent halo','🤠':'cowboy yeehaw','🤑':'money face rich','🤓':'nerd glasses','👿':'devil angry imp','🫶':'heart hands','🤧':'sneeze sick','😷':'mask sick','🤒':'thermometer sick','🤕':'bandage hurt','💅':'nail polish sassy',
'👋':'wave hello hi bye','🤚':'raised back hand','✋':'hand stop high five','🖖':'vulcan spock','👌':'ok okay perfect','🤌':'pinched italian','✌️':'peace victory','🤞':'crossed fingers luck','🤟':'love you hand','🤘':'rock on metal','🤙':'call me shaka hang loose','👈':'point left','👉':'point right','👆':'point up','👇':'point down','☝️':'index up','👍':'thumbs up like good yes','👎':'thumbs down dislike bad no','✊':'fist bump','👊':'punch fist bump','🤛':'left fist bump','🤜':'right fist bump','👏':'clap applause','🙌':'raising hands celebrate','🤝':'handshake deal','🙏':'pray please thank you namaste','💪':'strong muscle flex bicep','💃':'dancer dancing woman','🕺':'man dancing','🤳':'selfie','🖕':'middle finger','🫰':'pinch','🫳':'palm down','🫴':'palm up','👐':'open hands','🤲':'palms up','🫱':'right hand','🫲':'left hand','🤷':'shrug idk','🤦':'facepalm','🙇':'bow','💁':'info','🙆':'ok gesture','🙅':'no gesture','🙋':'raising hand hi','🧏':'deaf',
'🐶':'dog puppy','🐱':'cat kitty','🐭':'mouse','🐹':'hamster','🐰':'rabbit bunny','🦊':'fox','🐻':'bear','🐼':'panda','🐨':'koala','🐯':'tiger','🦁':'lion','🐮':'cow','🐷':'pig','🐸':'frog','🐔':'chicken','🐧':'penguin','🐦':'bird','🦆':'duck','🦅':'eagle','🦉':'owl','🐺':'wolf','🐴':'horse','🦄':'unicorn','🐝':'bee','🦋':'butterfly','🐌':'snail','🐞':'ladybug','🐢':'turtle','🐍':'snake','🐙':'octopus','🐬':'dolphin','🐳':'whale','🦈':'shark','🐊':'crocodile alligator','🦖':'trex dinosaur','🦕':'dinosaur brontosaurus',
'🍎':'apple red','🍐':'pear','🍊':'orange tangerine','🍋':'lemon','🍌':'banana','🍉':'watermelon','🍇':'grapes','🍓':'strawberry','🍒':'cherry','🍑':'peach','🍍':'pineapple','🍕':'pizza','🍔':'burger hamburger','🍟':'fries french','🌭':'hotdog','🍿':'popcorn','🧁':'cupcake','🍩':'donut','🍪':'cookie','🍰':'cake','🎂':'birthday cake','🧀':'cheese','🥚':'egg','🥓':'bacon','🌮':'taco','🍜':'noodles ramen','🍝':'spaghetti pasta','🍣':'sushi','☕':'coffee','🍺':'beer','🍷':'wine','🍾':'champagne',
'⚽':'soccer football','🏀':'basketball','🏈':'football american','🎮':'gaming controller video game','🕹️':'joystick arcade','🎲':'dice','🧩':'puzzle jigsaw','🎯':'bullseye target dart','🎨':'art palette paint','🎵':'music note','🎸':'guitar','🏆':'trophy winner','🎧':'headphones music','🎤':'microphone karaoke sing',
'🍎':'apple red','🍐':'pear','🍊':'orange tangerine','🍋':'lemon','🍌':'banana','🍉':'watermelon','🍇':'grapes','🍓':'strawberry','🍒':'cherry','🍑':'peach','🍍':'pineapple','🍕':'pizza','🍔':'burger hamburger','🍟':'fries french','🌭':'hotdog','🍿':'popcorn','🧁':'cupcake','🍩':'donut','🍪':'cookie','🍰':'cake','🎂':'birthday cake','🧀':'cheese','🥚':'egg','🥓':'bacon','🌮':'taco','🍜':'noodles ramen','🍝':'spaghetti pasta','🍣':'sushi','☕':'coffee','🍺':'beer','🍻':'clinking beers cheers toast','🍷':'wine','🍾':'champagne','🥂':'clinking glasses cheers toast','🥃':'tumbler whiskey bourbon','🍶':'sake','🫗':'pouring liquid','🍸':'cocktail martini','🍹':'tropical drink',
'⚽':'soccer football','🏀':'basketball','🏈':'football american','🎮':'gaming controller video game','🕹️':'joystick arcade','🎲':'dice','🧩':'puzzle jigsaw','🎯':'bullseye target dart','🎨':'art palette paint','🎵':'music note','🎶':'music notes melody song','🎸':'guitar','🏆':'trophy winner','🎧':'headphones music','🎤':'microphone karaoke sing','🎉':'party popper celebration tada','🎊':'confetti ball celebrate','🎈':'balloon party','🎀':'ribbon bow','🎗️':'reminder ribbon',
'🚗':'car automobile','🚀':'rocket space launch','✈️':'airplane plane travel','🏠':'house home','🏰':'castle','🌊':'wave ocean water','🌅':'sunrise','🌍':'globe earth world','🌈':'rainbow',
'❤️':'red heart love','🧡':'orange heart','💛':'yellow heart','💚':'green heart','💙':'blue heart','💜':'purple heart','🖤':'black heart','🤍':'white heart','💔':'broken heart','✨':'sparkles stars','⭐':'star','🔥':'fire hot lit','💯':'hundred perfect','✅':'check mark yes','❌':'cross mark no wrong','❗':'exclamation mark bang','❓':'question mark','❕':'white exclamation','❔':'white question','‼️':'double exclamation bangbang','⁉️':'exclamation question interrobang','💤':'sleep zzz','⚠️':'warning caution','⚡':'lightning bolt zap','☀️':'sun sunny','🌙':'moon crescent night','❄️':'snowflake cold winter','🌪️':'tornado','🔴':'red circle','🔵':'blue circle','🟢':'green circle','🟡':'yellow circle','🟠':'orange circle','🟣':'purple circle','⚫':'black circle','⚪':'white circle','©️':'copyright','®️':'registered','™️':'trademark','#️⃣':'hash number sign','*️⃣':'asterisk star keycap',
'❤️':'red heart love','🧡':'orange heart','💛':'yellow heart','💚':'green heart','💙':'blue heart','💜':'purple heart','🖤':'black heart','🤍':'white heart','💔':'broken heart','✨':'sparkles stars','⭐':'star','🔥':'fire hot lit','💯':'hundred perfect','✅':'check mark yes','❌':'cross mark no wrong','❗':'exclamation mark bang','❓':'question mark','❕':'white exclamation','❔':'white question','‼️':'double exclamation bangbang','⁉️':'exclamation question interrobang','!':'exclamation punctuation bang','?':'question punctuation mark',',':'comma punctuation','.':'period punctuation dot','💤':'sleep zzz','⚠️':'warning caution','⚡':'lightning bolt zap','☀️':'sun sunny','🌙':'moon crescent night','❄️':'snowflake cold winter','🌪️':'tornado','🔴':'red circle','🔵':'blue circle','🟢':'green circle','🟡':'yellow circle','🟠':'orange circle','🟣':'purple circle','⚫':'black circle','⚪':'white circle','©️':'copyright','®️':'registered','™️':'trademark','#️⃣':'hash number sign','*️⃣':'asterisk star keycap',
'🙈':'see no evil monkey','🙉':'hear no evil monkey','🙊':'speak no evil monkey',
'👀':'eyes looking','👅':'tongue','👄':'mouth lips','💋':'kiss lips','🧠':'brain smart','🦷':'tooth','🦴':'bone','💀':'skull dead','☠️':'skull crossbones','👽':'alien','🤖':'robot','🎃':'jack o lantern pumpkin halloween',
'📱':'phone mobile','💻':'laptop computer','📷':'camera photo','📚':'books reading','📝':'memo note write','🔑':'key','🔒':'lock locked','💎':'gem diamond jewel','🎁':'gift present','🔔':'bell notification','💰':'money bag rich','🔨':'hammer tool'
'📱':'phone mobile','💻':'laptop computer','📷':'camera photo','📚':'books reading','📝':'memo note write','🔑':'key','🔒':'lock locked','💎':'gem diamond jewel','🎁':'gift present','🔔':'bell notification','💰':'money bag rich','🔨':'hammer tool','💬':'speech bubble chat','💭':'thought bubble thinking','🗨️':'speech balloon','🗯️':'anger bubble','📣':'megaphone announcement','📢':'loudspeaker','👑':'crown king queen royal','💍':'ring diamond wedding','🕶️':'sunglasses cool'
};
if (!this.token || !this.user) {
// Preserve invite param so it survives the redirect to the auth page
const _inv = new URLSearchParams(window.location.search).get('invite');
if (_inv) sessionStorage.setItem('haven_pending_invite', _inv);
window.location.href = '/';
return;
}
@ -176,6 +186,8 @@ class HavenApp {
this._setupFontSizePicker();
this._setupEmojiSizePicker();
this._setupImageModePicker();
this._setupRoleDisplayPicker();
this._setupToolbarIconPicker();
this._setupLightbox();
this._setupOnlineOverlay();
this._setupModalExpand();
@ -225,6 +237,24 @@ class HavenApp {
document.getElementById('mod-mode-settings-toggle')?.addEventListener('click', () => this.modMode?.toggle());
}
async _loadBotCommands() {
try {
const res = await fetch('/api/bot-commands');
if (!res.ok) return;
const data = await res.json();
if (!data.commands || !data.commands.length) return;
const builtInCmds = new Set(this.slashCommands.map(c => c.cmd));
for (const bc of data.commands) {
if (builtInCmds.has(bc.command)) continue;
this.slashCommands.push({
cmd: bc.command,
args: '<...>',
desc: `${bc.description || 'Bot command'} [${bc.bot_name || 'Bot'}]`
});
}
} catch { /* non-critical */ }
}
}
// ── Merge all method groups onto the prototype ────────────

View file

@ -1,9 +1,28 @@
// ── Auth Page Logic (with theme support + i18n) ───────────────────────────
(async function () {
// Preserve invite param across login/register so vanity invite links work for new users
const _urlParams = new URLSearchParams(window.location.search);
const _pendingInvite = _urlParams.get('invite') || sessionStorage.getItem('haven_pending_invite') || '';
if (_pendingInvite) sessionStorage.setItem('haven_pending_invite', _pendingInvite);
// Preserve channel/message deep-link params (?channel=CODE&message=ID) too
const _pendingChannel = _urlParams.get('channel') || sessionStorage.getItem('haven_pending_channel') || '';
const _pendingMessage = _urlParams.get('message') || sessionStorage.getItem('haven_pending_message') || '';
if (_pendingChannel) sessionStorage.setItem('haven_pending_channel', _pendingChannel);
if (_pendingMessage) sessionStorage.setItem('haven_pending_message', _pendingMessage);
const _appQuery = (() => {
const parts = [];
if (_pendingInvite) parts.push('invite=' + encodeURIComponent(_pendingInvite));
if (_pendingChannel) parts.push('channel=' + encodeURIComponent(_pendingChannel));
if (_pendingMessage) parts.push('message=' + encodeURIComponent(_pendingMessage));
return parts.length ? '?' + parts.join('&') : '';
})();
const _appUrl = '/app' + _appQuery;
// If already logged in, redirect to app
if (localStorage.getItem('haven_token')) {
window.location.href = '/app';
window.location.href = _appUrl;
return;
}
@ -116,6 +135,7 @@
const tabs = document.querySelectorAll('.auth-tab');
const loginForm = document.getElementById('login-form');
const registerForm = document.getElementById('register-form');
const ssoForm = document.getElementById('sso-form');
const totpForm = document.getElementById('totp-form');
const errorEl = document.getElementById('auth-error');
@ -125,6 +145,7 @@
function showTotpForm() {
loginForm.style.display = 'none';
registerForm.style.display = 'none';
if (ssoForm) ssoForm.style.display = 'none';
totpForm.style.display = 'flex';
document.querySelector('.auth-tabs').style.display = 'none';
document.getElementById('totp-code').value = '';
@ -148,6 +169,7 @@
const target = tab.dataset.tab;
loginForm.style.display = target === 'login' ? 'flex' : 'none';
registerForm.style.display = target === 'register' ? 'flex' : 'none';
if (ssoForm) ssoForm.style.display = target === 'sso' ? 'flex' : 'none';
totpForm.style.display = 'none';
document.getElementById('recover-form').style.display = 'none';
hideError();
@ -188,7 +210,7 @@
sessionStorage.setItem('haven_e2e_wrap', e2eWrap);
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
window.location.href = '/app';
window.location.href = _appUrl;
} catch {
showError(t('auth.errors.connection_error'));
}
@ -200,6 +222,7 @@
function showRecoverForm() {
loginForm.style.display = 'none';
registerForm.style.display = 'none';
if (ssoForm) ssoForm.style.display = 'none';
totpForm.style.display = 'none';
recoverForm.style.display = 'flex';
document.querySelector('.auth-tabs').style.display = 'none';
@ -288,7 +311,7 @@
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
window.location.href = '/app';
window.location.href = _appUrl;
} catch (err) {
showError(t('auth.errors.connection_error'));
}
@ -321,7 +344,7 @@
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
_pendingChallenge = null;
window.location.href = '/app';
window.location.href = _appUrl;
} catch (err) {
showError(t('auth.errors.connection_error'));
}
@ -362,6 +385,254 @@
});
}
// ── SSO (Link Server) ──────────────────────────────────
if (ssoForm) {
// Populate the recent-servers datalist from localStorage
try {
const servers = JSON.parse(localStorage.getItem('haven_servers') || '[]');
const datalist = document.getElementById('sso-recent-servers');
if (datalist && Array.isArray(servers)) {
for (const s of servers) {
if (s.url) {
const opt = document.createElement('option');
opt.value = s.url;
if (s.name) opt.label = s.name;
datalist.appendChild(opt);
}
}
}
} catch { /* ignore */ }
let ssoAuthCode = null;
let ssoServerUrl = null;
let ssoProfileData = null;
let ssoWaiting = false;
let ssoPollTimer = null;
let ssoTimeoutTimer = null;
const ssoConnectBtn = document.getElementById('sso-connect-btn');
const ssoStepServer = document.getElementById('sso-step-server');
const ssoStepRegister = document.getElementById('sso-step-register');
const ssoPreviewAvatar = document.getElementById('sso-preview-avatar');
const ssoPreviewUsername = document.getElementById('sso-preview-username');
const ssoRegisterBtn = document.getElementById('sso-register-btn');
const ssoBackBtn = document.getElementById('sso-back-btn');
const ssoServerInput = document.getElementById('sso-server-url');
const stopSsoPolling = () => {
if (ssoPollTimer) {
clearInterval(ssoPollTimer);
ssoPollTimer = null;
}
if (ssoTimeoutTimer) {
clearTimeout(ssoTimeoutTimer);
ssoTimeoutTimer = null;
}
};
const getSsoOrigin = () => {
try { return new URL(ssoServerUrl).origin; } catch { return ssoServerUrl; }
};
const applySsoProfile = (profile, sourceOrigin = null) => {
if (!profile) return;
ssoProfileData = profile;
ssoWaiting = false;
stopSsoPolling();
ssoConnectBtn.textContent = 'Connect';
ssoConnectBtn.disabled = false;
const profileUsername = (typeof ssoProfileData.username === 'string' ? ssoProfileData.username.trim() : '');
const previewName = (typeof ssoProfileData.displayName === 'string' ? ssoProfileData.displayName.trim() : '') || profileUsername;
if (ssoProfileData.profilePicture) {
let src = ssoProfileData.profilePicture;
if (src.startsWith('/')) {
const base = sourceOrigin || getSsoOrigin();
src = base + src;
}
ssoPreviewAvatar.innerHTML = `<img src="${src}" style="width:100%;height:100%;object-fit:cover" alt="">`;
} else {
ssoPreviewAvatar.textContent = (previewName || '?')[0].toUpperCase();
}
ssoPreviewUsername.textContent = previewName || '—';
ssoStepServer.style.display = 'none';
ssoStepRegister.style.display = '';
hideError();
};
const tryFetchSsoProfile = async (surfaceError = false) => {
if (!ssoWaiting || !ssoAuthCode || !ssoServerUrl) return false;
try {
const res = await fetch(`${ssoServerUrl}/api/auth/SSO/authenticate?authCode=${encodeURIComponent(ssoAuthCode)}`);
if (!res.ok) {
if (surfaceError && res.status !== 404) {
const data = await res.json().catch(() => ({}));
showError(data.error || 'SSO failed — please try again');
}
return false;
}
const data = await res.json();
applySsoProfile(data, getSsoOrigin());
return true;
} catch {
if (surfaceError) showError('Could not reach home server — please try again');
return false;
}
};
function ssoReset() {
stopSsoPolling();
ssoAuthCode = null;
ssoServerUrl = null;
ssoProfileData = null;
ssoWaiting = false;
ssoStepServer.style.display = '';
ssoStepRegister.style.display = 'none';
ssoPreviewAvatar.innerHTML = '?';
ssoPreviewUsername.textContent = '—';
document.getElementById('sso-password').value = '';
document.getElementById('sso-confirm').value = '';
hideError();
}
// Step 1 — Connect to home server
ssoConnectBtn.addEventListener('click', () => {
hideError();
let raw = ssoServerInput.value.trim();
if (!raw) return showError('Enter the address of your Haven server');
// Normalise the URL
raw = raw.replace(/\/+$/, '');
if (!/^https?:\/\//i.test(raw)) {
raw = (raw.startsWith('localhost') || raw.startsWith('127.0.0.1'))
? 'http://' + raw
: 'https://' + raw;
}
ssoServerUrl = raw;
// Generate a cryptographically secure auth code
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
ssoAuthCode = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
// Open the consent page on the home server in a new tab
const consentUrl = `${ssoServerUrl}/api/auth/SSO?authCode=${encodeURIComponent(ssoAuthCode)}&origin=${encodeURIComponent(window.location.origin)}`;
window.open(consentUrl, '_blank');
ssoWaiting = true;
ssoConnectBtn.textContent = 'Waiting for approval…';
ssoConnectBtn.disabled = true;
stopSsoPolling();
ssoPollTimer = setInterval(() => {
tryFetchSsoProfile(false);
}, 2000);
ssoTimeoutTimer = setTimeout(() => {
if (!ssoWaiting) return;
ssoWaiting = false;
stopSsoPolling();
ssoConnectBtn.textContent = 'Connect';
ssoConnectBtn.disabled = false;
showError('SSO approval timed out — try connecting again');
}, 90000);
});
// When user returns to this tab after approving on home server
window.addEventListener('focus', async () => {
if (!ssoWaiting || !ssoAuthCode || !ssoServerUrl) return;
await tryFetchSsoProfile(true);
});
// Preferred path: SSO popup posts profile data back to this window.
window.addEventListener('message', (event) => {
if (!ssoWaiting || !ssoAuthCode || !ssoServerUrl) return;
const data = event.data || {};
if (data.type !== 'haven-sso-approved') return;
if (data.authCode !== ssoAuthCode) return;
const expectedOrigin = getSsoOrigin();
if (event.origin !== expectedOrigin) return;
if (!data.profile || !data.profile.username) return;
applySsoProfile(data.profile, data.serverOrigin || expectedOrigin);
});
// Back button — return to step 1
ssoBackBtn.addEventListener('click', (e) => {
e.preventDefault();
ssoReset();
});
// Step 2 — Register with imported profile
ssoRegisterBtn.addEventListener('click', async () => {
hideError();
if (!checkEula()) return;
if (!ssoProfileData) return showError('Please connect to your home server first');
const password = document.getElementById('sso-password').value;
const confirm = document.getElementById('sso-confirm').value;
if (!password || !confirm) return showError(t('auth.errors.fill_all_fields'));
if (password.length < 8) return showError(t('auth.errors.password_too_short'));
if (password !== confirm) return showError(t('auth.errors.passwords_no_match'));
// Prefer canonical username from SSO payload. If a legacy server sends
// display-name-like values, normalize into a valid Haven username.
const normalizeUsername = (value) => {
if (typeof value !== 'string') return '';
return value
.trim()
.replace(/[^a-zA-Z0-9_]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 20);
};
let registerUsername = normalizeUsername(ssoProfileData.username);
if (registerUsername.length < 3) {
registerUsername = normalizeUsername(ssoProfileData.displayName);
}
if (registerUsername.length < 3) {
return showError('SSO username is invalid. Please use standard registration.');
}
// Build the full profile picture URL for the server to download
let profilePicUrl = ssoProfileData.profilePicture || null;
if (profilePicUrl && profilePicUrl.startsWith('/')) {
profilePicUrl = ssoServerUrl + profilePicUrl;
}
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: registerUsername,
password,
eulaVersion: '2.0',
ageVerified: true,
ssoProfilePicture: profilePicUrl
})
});
const data = await res.json();
if (!res.ok) return showError(data.error || t('auth.errors.registration_failed'));
// Derive E2E wrapping key from password
const e2eWrap = await deriveE2EWrappingKey(password);
sessionStorage.setItem('haven_e2e_wrap', e2eWrap);
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
window.location.href = _appUrl;
} catch (err) {
showError(t('auth.errors.connection_error'));
}
});
}
// ── Register ──────────────────────────────────────────
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
@ -393,7 +664,7 @@
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
window.location.href = '/app';
window.location.href = _appUrl;
} catch (err) {
showError(t('auth.errors.connection_error'));
}

View file

@ -68,38 +68,72 @@ class HavenE2E {
await this._openDB();
this._keysWereReset = false;
this._serverBackupExists = false;
this._serverBackupState = 'unknown'; // 'present' | 'none' | 'unknown'
this._divergent = false; // local pub != server pub
this._ghostState = false; // init aborted to protect a possibly-good server backup
/* 1. Fast path — local IndexedDB */
this._keyPair = await this._loadLocal();
/* 1b. If loaded from IndexedDB but server backup is gone (e.g. after
* account recovery), re-upload so cross-device sync and the public
* key endpoint stay valid. Uses the current wrapping key. */
/* 1b. If loaded from IndexedDB, probe server state explicitly.
* Three outcomes:
* present verify local pub matches server pub; flag divergence if not
* none server actually has no backup; re-upload ours
* unknown request timed out; do NOT mutate server state */
if (this._keyPair && socket && wrappingKey) {
const backup = await this._fetchBackup(socket);
if (backup) {
const probe = await this._fetchBackupWithState(socket);
this._serverBackupState = probe.status;
if (probe.status === 'present') {
this._serverBackupExists = true;
} else {
// Server backup was cleared — re-upload with the current wrapping key
try { await this._uploadBackup(socket, wrappingKey); }
try {
const localPub = await crypto.subtle.exportKey('jwk', this._keyPair.publicKey);
if (probe.serverPublicKey && localPub && probe.serverPublicKey.x && localPub.x && probe.serverPublicKey.x !== localPub.x) {
this._divergent = true;
console.warn('[E2E] Local key diverges from server backup — awaiting user action');
}
} catch { /* best-effort divergence check */ }
} else if (probe.status === 'none') {
// Server confirmed empty — safe to re-upload our local key
try { await this._uploadBackup(socket, wrappingKey); this._serverBackupExists = true; }
catch (err) { console.warn('[E2E] Re-upload after recovery failed:', err.message); }
} else {
// probe.status === 'unknown' — flaky network. Do NOT upload; it would
// clobber whatever the server actually has.
console.warn('[E2E] Could not reach server for backup probe — skipping re-upload to avoid clobber');
}
}
/* 2. Cross-device — try server backup (only if we have a wrapping key) */
if (!this._keyPair && socket && wrappingKey) {
this._keyPair = await this._restoreFromServer(socket, wrappingKey);
const restored = await this._restoreFromServerWithState(socket, wrappingKey);
this._keyPair = restored.pair;
this._serverBackupState = restored.status;
if (restored.status === 'present') this._serverBackupExists = true;
}
/* 3. No key anywhere generate ONLY if no backup exists on the server.
* If server backup exists but unwrap failed, do NOT generate new keys
* (that would overwrite the existing key and break other devices).
* Instead, leave E2E unavailable and let the user resolve. */
if (!this._keyPair && wrappingKey && !this._serverBackupExists) {
/* 3. No key anywhere generate ONLY if we affirmatively confirmed the
* server has no backup. On 'unknown' we bail out entirely: generating
* a fresh key here and uploading would clobber a potentially-good
* backup once the network comes back. A 5-min cooldown prevents
* flap-regenerate storms across reconnects. */
if (!this._keyPair && wrappingKey) {
if (this._serverBackupState !== 'none') {
this._ghostState = true;
console.warn('[E2E] Server state ' + this._serverBackupState + ' — refusing to generate keys (would risk clobber)');
this._ready = false;
return false;
}
if (!(await this._canAttemptGenerate())) {
this._ghostState = true;
console.warn('[E2E] Regenerate cooldown active — refusing to mint new keypair');
this._ready = false;
return false;
}
this._keyPair = await this._generate();
await this._saveLocal(this._keyPair);
await this._markGenerateAttempt();
this._freshlyGenerated = true;
console.log('[E2E] Generated new key pair (first-time setup)');
console.log('[E2E] Generated new key pair (first-time setup, server confirmed empty)');
}
/* 4. Auto-login without IndexedDB — E2E unavailable until real login */
@ -136,6 +170,15 @@ class HavenE2E {
}
}
/** True if local and server backups disagree on the public key. UI can prompt user to sync or reset. */
get divergent() { return !!this._divergent; }
/** True if init bailed out to protect a possibly-good server backup. UI should prompt for action. */
get ghostState() { return !!this._ghostState; }
/** Last observed server backup state: 'present' | 'none' | 'unknown'. */
get serverBackupState() { return this._serverBackupState || 'unknown'; }
/**
* Generate fresh keys, upload backup, publish to server.
* Old encrypted messages become permanently unreadable.
@ -354,14 +397,56 @@ class HavenE2E {
/* ─── Server communication ────────────────────────── */
_fetchBackup(socket) {
return new Promise(resolve => {
const t = setTimeout(() => resolve(null), 5000);
return this._fetchBackupWithState(socket).then(r => r.status === 'present' ? r.data : null);
}
/**
* Fetch encrypted key backup with explicit status tracking.
* Returns { status, data, serverPublicKey }:
* status = 'present' server returned a backup blob
* status = 'none' server confirmed there is no backup (safe to generate)
* status = 'unknown' request timed out or errored (do NOT treat as empty)
* Retries once before giving up, so transient network blips aren't treated as "no backup".
* This distinction is load-bearing: the previous 5s-timeout-returns-null design
* conflated "confirmed empty" with "unreachable", which could let the client
* overwrite a good server backup after flaky mobile reconnects.
*/
_fetchBackupWithState(socket) {
const TIMEOUT = 15000;
const attempt = () => new Promise(resolve => {
let done = false;
const t = setTimeout(() => {
if (done) return;
done = true;
resolve({ status: 'unknown', data: null, serverPublicKey: null });
}, TIMEOUT);
socket.once('encrypted-key-result', data => {
if (done) return;
done = true;
clearTimeout(t);
resolve(data && data.encryptedKey && data.salt ? data : null);
const hasBlob = data && data.encryptedKey && data.salt;
if (hasBlob) {
resolve({ status: 'present', data, serverPublicKey: data.publicKey || null });
} else if (data && (data.state === 'empty' || (data.state === undefined && data.hasPublicKey === false))) {
// Server-confirmed empty. Legacy servers (no `state` field) fall back to
// the hasPublicKey heuristic: if the user has no public key either, the
// account is truly fresh.
resolve({ status: 'none', data: null, serverPublicKey: null });
} else if (data && data.state === 'empty') {
resolve({ status: 'none', data: null, serverPublicKey: null });
} else {
// Legacy server returned null blob but has a public key — ambiguous.
// Treat as unknown to be safe; we'd rather retry than risk a clobber.
resolve({ status: 'unknown', data: null, serverPublicKey: null });
}
});
socket.emit('get-encrypted-key');
});
return attempt().then(first => {
if (first.status !== 'unknown') return first;
console.warn('[E2E] Backup fetch timed out, retrying once');
return attempt();
});
}
async _uploadBackup(socket, secret) {
@ -424,21 +509,69 @@ class HavenE2E {
/* ─── Server restore helper ───────────────────────── */
async _restoreFromServer(socket, secret) {
const backup = await this._fetchBackup(socket);
if (!backup) return null;
const r = await this._restoreFromServerWithState(socket, secret);
return r.pair;
}
/**
* Restore with status awareness. Returns { pair, status }:
* status = 'present' backup found (pair may still be null if unwrap failed)
* status = 'none' server confirmed empty; caller may generate fresh keys
* status = 'unknown' network issue; caller MUST NOT overwrite server state
*/
async _restoreFromServerWithState(socket, secret) {
const probe = await this._fetchBackupWithState(socket);
if (probe.status !== 'present') return { pair: null, status: probe.status };
this._serverBackupExists = true;
try {
const jwk = await this._unwrap(secret, backup.encryptedKey, backup.salt);
const jwk = await this._unwrap(secret, probe.data.encryptedKey, probe.data.salt);
const pair = await this._importPair(jwk);
await this._saveLocal(pair);
console.log('[E2E] Restored from server backup');
return pair;
return { pair, status: 'present' };
} catch {
console.warn('[E2E] Server backup unwrap failed — keys NOT auto-regenerated to protect other devices');
return null;
return { pair: null, status: 'present' };
}
}
/* ─── Regenerate cooldown ─────────────────────────── */
_cooldownGet() {
return new Promise(resolve => {
try {
const tx = this._db.transaction('keys', 'readonly');
const r = tx.objectStore('keys').get('last_generate_attempt');
tx.oncomplete = () => resolve(typeof r.result === 'number' ? r.result : 0);
tx.onerror = () => resolve(0);
} catch { resolve(0); }
});
}
_cooldownSet(val) {
return new Promise(resolve => {
try {
const tx = this._db.transaction('keys', 'readwrite');
const s = tx.objectStore('keys');
if (val === null) s.delete('last_generate_attempt');
else s.put(val, 'last_generate_attempt');
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
} catch { resolve(); }
});
}
async _canAttemptGenerate() {
const last = await this._cooldownGet();
if (!last) return true;
return Date.now() - last >= 5 * 60 * 1000;
}
async _markGenerateAttempt() { await this._cooldownSet(Date.now()); }
/** Clear the regenerate cooldown (e.g. on explicit user-driven reset). */
async clearGenerateCooldown() { await this._cooldownSet(null); }
/**
* Sync keys from the server backup (clears local keys first).
* Used when another device changed the key and this device needs to catch up.

View file

@ -5,7 +5,7 @@ const ALL_PERMS = [
'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', 'view_channel_members', 'manage_emojis', 'manage_soundboard', 'manage_music_queue', 'promote_user', 'transfer_admin',
'manage_roles', 'manage_server', 'delete_channel'
'manage_roles', 'manage_server', 'delete_channel', 'read_only_override'
];
//Similarly flavored solution to perm labels
const PERM_LABELS = {
@ -39,7 +39,8 @@ const PERM_LABELS = {
get transfer_admin() { return t('permissions.transfer_admin'); },
get manage_roles() { return t('permissions.manage_roles'); },
get manage_server() { return t('permissions.manage_server'); },
get delete_channel() { return t('permissions.delete_channel'); }
get delete_channel() { return t('permissions.delete_channel'); },
get read_only_override() { return t('permissions.read_only_override'); }
};
export default {
@ -318,6 +319,10 @@ _applyServerSettings() {
if (titleInput && this.serverSettings.server_title !== undefined) {
titleInput.value = this.serverSettings.server_title || '';
}
const welcomeInput = document.getElementById('welcome-message-input');
if (welcomeInput) {
welcomeInput.value = this.serverSettings.welcome_message || '';
}
const cleanupEnabled = document.getElementById('cleanup-enabled');
if (cleanupEnabled) {
cleanupEnabled.checked = this.serverSettings.cleanup_enabled === 'true';
@ -378,6 +383,75 @@ _applyServerSettings() {
serverCodeEl.style.opacity = code ? '1' : '0.4';
}
// Vanity code — update input if modal is open
if (!modalOpen) {
const vanityInput = document.getElementById('vanity-code-input');
if (vanityInput) vanityInput.value = this.serverSettings.vanity_code || '';
}
// Server banner — always update display (display prefs from localStorage)
const bannerDisplay = document.getElementById('server-banner-display');
const bannerImg = document.getElementById('server-banner-img');
const bannerPreview = document.getElementById('server-banner-preview');
const mainEl = document.querySelector('.main');
const headerMode = localStorage.getItem('haven_banner_header_mode') || 'full';
const bannerHeight = parseInt(localStorage.getItem('haven_banner_height')) || 180;
const bannerOffset = parseInt(localStorage.getItem('haven_banner_offset')) || 0;
const hasBanner = !!this.serverSettings.server_banner;
// Show/hide the banner display section in user settings
const bannerSection = document.getElementById('section-banner-display');
if (bannerSection) bannerSection.style.display = hasBanner ? '' : 'none';
if (bannerDisplay && bannerImg) {
if (hasBanner) {
bannerImg.src = this.serverSettings.server_banner;
bannerDisplay.style.display = '';
bannerDisplay.style.height = bannerHeight + 'px';
bannerImg.style.objectPosition = 'center ' + bannerOffset + '%';
mainEl?.classList.add('has-banner');
mainEl?.classList.remove('banner-mode-shaded', 'banner-mode-minimal', 'banner-mode-transparent');
if (headerMode !== 'full') {
mainEl?.classList.add('banner-mode-' + headerMode);
}
} else {
bannerDisplay.style.display = 'none';
bannerImg.src = '';
mainEl?.classList.remove('has-banner', 'banner-mode-shaded', 'banner-mode-minimal', 'banner-mode-transparent');
}
}
// Banner header mode dropdown (user settings)
const headerModeSelect = document.getElementById('banner-header-mode');
if (headerModeSelect) headerModeSelect.value = headerMode;
// Banner height slider (user settings)
const heightSlider = document.getElementById('banner-height-slider');
const heightValue = document.getElementById('banner-height-value');
if (heightSlider) {
heightSlider.value = bannerHeight;
if (heightValue) heightValue.textContent = bannerHeight + 'px';
}
// Banner offset slider (user settings)
const offsetSlider = document.getElementById('banner-offset-slider');
const offsetValue = document.getElementById('banner-offset-value');
if (offsetSlider) {
offsetSlider.value = bannerOffset;
if (offsetValue) offsetValue.textContent = bannerOffset + '%';
}
// Role icon display checkboxes
const riSidebar = document.getElementById('role-icon-sidebar');
if (riSidebar) riSidebar.checked = (this.serverSettings.role_icon_sidebar || 'true') === 'true';
const riChat = document.getElementById('role-icon-chat');
if (riChat) riChat.checked = this.serverSettings.role_icon_chat === 'true';
const riAfter = document.getElementById('role-icon-after-name');
if (riAfter) riAfter.checked = this.serverSettings.role_icon_after_name === 'true';
if (bannerPreview) {
if (this.serverSettings.server_banner) {
bannerPreview.innerHTML = `<img src="${this._escapeHtml(this.serverSettings.server_banner)}" style="max-width:100%;max-height:80px;border-radius:6px;object-fit:cover">`;
} else {
bannerPreview.innerHTML = '<span class="muted-text" style="font-size:11px">No banner</span>';
}
}
// Always update visual branding regardless of modal state
this._applyServerBranding();
@ -413,28 +487,47 @@ _renderWebhooksList(webhooks) {
_syncSettingsNav() {
const isAdmin = document.getElementById('admin-mod-panel')?.style.display !== 'none';
// Show/hide individual admin nav items
document.querySelectorAll('.settings-nav-admin').forEach(el => {
el.style.display = isAdmin ? '' : 'none';
});
// Show/hide the admin tab button in settings header
const adminTab = document.querySelector('.settings-tab-admin');
if (adminTab) adminTab.style.display = isAdmin ? '' : 'none';
// Show/hide the admin save bar (only visible when admin tab is active)
const saveBar = document.querySelector('.admin-save-bar');
if (saveBar) {
const adminTabActive = adminTab?.classList.contains('active');
saveBar.style.display = (isAdmin && adminTabActive) ? '' : 'none';
}
// Show the Emojis settings tab for users with manage_emojis permission even if not full admin/mod
const emojiNavItem = document.querySelector('.settings-nav-item[data-target="section-emojis"]');
if (emojiNavItem && !isAdmin && this._hasPerm('manage_emojis')) {
emojiNavItem.style.display = '';
if (adminTab) adminTab.style.display = '';
}
// Show the Sounds admin tab for users with manage_soundboard permission
const soundsNavItem = document.querySelector('.settings-nav-item[data-target="section-sounds-admin"]');
if (soundsNavItem && !isAdmin && this._hasPerm('manage_soundboard')) {
soundsNavItem.style.display = '';
if (adminTab) adminTab.style.display = '';
}
// Show Roles tab for users with manage_roles permission
const rolesNavItem = document.querySelector('.settings-nav-item[data-target="section-roles"]');
if (rolesNavItem && !isAdmin && this._hasPerm('manage_roles')) {
rolesNavItem.style.display = '';
if (adminTab) adminTab.style.display = '';
}
// Show Server settings tab for users with manage_server permission
const serverNavItem = document.querySelector('.settings-nav-item[data-target="section-server"]');
if (serverNavItem && !isAdmin && this._hasPerm('manage_server')) {
serverNavItem.style.display = '';
if (adminTab) adminTab.style.display = '';
}
// Also show save bar for users with manage_server perm (when admin tab active)
if (saveBar && !isAdmin && this._hasPerm('manage_server')) {
const adminTabActive = adminTab?.classList.contains('active');
if (adminTabActive) saveBar.style.display = '';
}
},
@ -442,6 +535,7 @@ _snapshotAdminSettings() {
this._adminSnapshot = {
server_name: this.serverSettings.server_name || 'HAVEN',
server_title: this.serverSettings.server_title || '',
welcome_message: this.serverSettings.welcome_message || '',
member_visibility: this.serverSettings.member_visibility || 'online',
cleanup_enabled: this.serverSettings.cleanup_enabled || 'false',
cleanup_max_age_days: this.serverSettings.cleanup_max_age_days || '0',
@ -453,7 +547,10 @@ _snapshotAdminSettings() {
max_poll_options: this.serverSettings.max_poll_options || '10',
update_banner_admin_only: this.serverSettings.update_banner_admin_only || 'false',
default_theme: this.serverSettings.default_theme || '',
custom_tos: this.serverSettings.custom_tos || ''
custom_tos: this.serverSettings.custom_tos || '',
role_icon_sidebar: this.serverSettings.role_icon_sidebar || 'true',
role_icon_chat: this.serverSettings.role_icon_chat || 'false',
role_icon_after_name: this.serverSettings.role_icon_after_name || 'false'
};
const tosEl = document.getElementById('custom-tos-input');
if (tosEl) tosEl.value = this._adminSnapshot.custom_tos;
@ -483,6 +580,12 @@ _saveAdminSettings() {
changed = true;
}
const welcomeMsg = document.getElementById('welcome-message-input')?.value.trim() || '';
if (welcomeMsg !== (snap.welcome_message || '')) {
this.socket.emit('update-server-setting', { key: 'welcome_message', value: welcomeMsg });
changed = true;
}
const vis = document.getElementById('member-visibility-select')?.value;
if (vis && vis !== snap.member_visibility) {
this.socket.emit('update-server-setting', { key: 'member_visibility', value: vis });
@ -556,6 +659,24 @@ _saveAdminSettings() {
changed = true;
}
const roleIconSidebar = document.getElementById('role-icon-sidebar')?.checked ? 'true' : 'false';
if (roleIconSidebar !== (snap.role_icon_sidebar || 'true')) {
this.socket.emit('update-server-setting', { key: 'role_icon_sidebar', value: roleIconSidebar });
changed = true;
}
const roleIconChat = document.getElementById('role-icon-chat')?.checked ? 'true' : 'false';
if (roleIconChat !== (snap.role_icon_chat || 'false')) {
this.socket.emit('update-server-setting', { key: 'role_icon_chat', value: roleIconChat });
changed = true;
}
const roleIconAfterName = document.getElementById('role-icon-after-name')?.checked ? 'true' : 'false';
if (roleIconAfterName !== (snap.role_icon_after_name || 'false')) {
this.socket.emit('update-server-setting', { key: 'role_icon_after_name', value: roleIconAfterName });
changed = true;
}
if (changed) {
this._showToast(t('settings.admin.settings_saved'), 'success');
} else {
@ -729,6 +850,86 @@ _initServerBranding() {
this.socket.emit('update-server-setting', { key: 'server_icon', value: '' });
this._showToast(t('settings.admin.server_icon_removed'), 'success');
});
// Server banner upload
document.getElementById('server-banner-upload-btn')?.addEventListener('click', async () => {
const fileInput = document.getElementById('server-banner-file');
if (!fileInput || !fileInput.files[0]) return this._showToast('Select an image first', 'error');
const form = new FormData();
form.append('image', fileInput.files[0]);
try {
const res = await fetch('/api/upload-server-banner', {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.token}` },
body: form
});
const data = await res.json();
if (data.error) return this._showToast(data.error, 'error');
this.socket.emit('update-server-setting', { key: 'server_banner', value: data.url });
this._showToast('Server banner updated', 'success');
fileInput.value = '';
} catch (err) {
this._showToast('Upload failed', 'error');
}
});
// Server banner remove
document.getElementById('server-banner-remove-btn')?.addEventListener('click', () => {
this.socket.emit('update-server-setting', { key: 'server_banner', value: '' });
this._showToast('Server banner removed', 'success');
});
// Banner header mode dropdown (client-side / localStorage)
document.getElementById('banner-header-mode')?.addEventListener('change', (e) => {
localStorage.setItem('haven_banner_header_mode', e.target.value);
this._applyServerSettings();
const labels = { full: 'Full header (opaque)', shaded: 'Shaded header', minimal: 'Minimal header', transparent: 'Transparent header' };
this._showToast(labels[e.target.value] || 'Header mode updated', 'success');
});
// Banner height slider (client-side / localStorage)
const bannerSlider = document.getElementById('banner-height-slider');
const bannerSliderLabel = document.getElementById('banner-height-value');
if (bannerSlider) {
bannerSlider.addEventListener('input', (e) => {
if (bannerSliderLabel) bannerSliderLabel.textContent = e.target.value + 'px';
const bd = document.getElementById('server-banner-display');
if (bd) bd.style.height = e.target.value + 'px';
});
bannerSlider.addEventListener('change', (e) => {
localStorage.setItem('haven_banner_height', e.target.value);
});
}
// Banner vertical offset slider (client-side / localStorage)
const bannerOffsetSlider = document.getElementById('banner-offset-slider');
const bannerOffsetLabel = document.getElementById('banner-offset-value');
if (bannerOffsetSlider) {
bannerOffsetSlider.addEventListener('input', (e) => {
if (bannerOffsetLabel) bannerOffsetLabel.textContent = e.target.value + '%';
const img = document.getElementById('server-banner-img');
if (img) img.style.objectPosition = 'center ' + e.target.value + '%';
});
bannerOffsetSlider.addEventListener('change', (e) => {
localStorage.setItem('haven_banner_offset', e.target.value);
});
}
// Vanity code
document.getElementById('vanity-code-save-btn')?.addEventListener('click', () => {
const val = document.getElementById('vanity-code-input')?.value.trim() || '';
if (val && (val.length < 3 || val.length > 32 || !/^[a-zA-Z0-9_-]+$/.test(val))) {
return this._showToast('Vanity code must be 3-32 chars (letters, numbers, hyphens, underscores)', 'error');
}
this.socket.emit('update-server-setting', { key: 'vanity_code', value: val });
this._showToast(val ? 'Vanity invite link saved' : 'Vanity invite link cleared', 'success');
});
document.getElementById('vanity-code-clear-btn')?.addEventListener('click', () => {
document.getElementById('vanity-code-input').value = '';
this.socket.emit('update-server-setting', { key: 'vanity_code', value: '' });
this._showToast('Vanity invite link cleared', 'success');
});
},
_renderBanList(bans) {
@ -1117,8 +1318,9 @@ _openMemberChannelPicker(userId, username, mode) {
// @MENTION AUTOCOMPLETE
// ═══════════════════════════════════════════════════════
_checkMentionTrigger() {
const input = document.getElementById('message-input');
_checkMentionTrigger(inputEl) {
const input = inputEl || document.getElementById('message-input');
this._mentionInput = input;
const cursor = input.selectionStart;
const text = input.value.substring(0, cursor);
@ -1181,7 +1383,7 @@ _navigateMentionDropdown(direction) {
},
_insertMention(username) {
const input = document.getElementById('message-input');
const input = this._mentionInput || document.getElementById('message-input');
const before = input.value.substring(0, this.mentionStart);
const after = input.value.substring(input.selectionStart);
input.value = before + '@' + username + ' ' + after;
@ -1194,8 +1396,9 @@ _insertMention(username) {
// EMOJI AUTOCOMPLETE (:name)
// ═══════════════════════════════════════════════════════
_checkEmojiTrigger() {
const input = document.getElementById('message-input');
_checkEmojiTrigger(inputEl) {
const input = inputEl || document.getElementById('message-input');
this._emojiAcInput = input;
const text = input.value;
const cursor = input.selectionStart;
@ -1297,7 +1500,7 @@ _navigateEmojiDropdown(dir) {
},
_insertEmojiAc(insert) {
const input = document.getElementById('message-input');
const input = this._emojiAcInput || document.getElementById('message-input');
const before = input.value.substring(0, this._emojiColonStart);
const after = input.value.substring(input.selectionStart);
input.value = before + insert + ' ' + after;
@ -1471,15 +1674,33 @@ _toggleStatusPicker() {
// Position the fixed picker relative to the status dot
if (dot) {
const rect = dot.getBoundingClientRect();
picker.style.left = rect.left + 'px';
// Open above or below depending on space
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceBelow > 220) {
picker.style.top = (rect.bottom + 4) + 'px';
picker.style.bottom = 'auto';
} else {
const isMobile = window.innerWidth <= 480;
// On mobile, center horizontally and open above the user bar
if (isMobile) {
picker.style.left = '10px';
picker.style.right = '10px';
picker.style.width = 'auto';
picker.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
picker.style.top = 'auto';
// Clamp so it doesn't go above the safe area
const maxBottom = window.innerHeight - 10;
const computedBottom = window.innerHeight - rect.top + 4;
if (computedBottom > maxBottom) {
picker.style.bottom = maxBottom + 'px';
}
} else {
picker.style.left = rect.left + 'px';
picker.style.right = 'auto';
picker.style.width = '220px';
// Open above or below depending on space
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceBelow > 220) {
picker.style.top = (rect.bottom + 4) + 'px';
picker.style.bottom = 'auto';
} else {
picker.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
picker.style.top = 'auto';
}
}
}
picker.style.display = 'block';
@ -2459,6 +2680,14 @@ _renderRoleDetail() {
<input type="number" class="settings-number-input" id="role-edit-level" value="${role.level}" min="1" max="99">
<label class="settings-label" style="margin-top:8px;">${t('settings.admin.role_form.color')}</label>
<input type="color" id="role-edit-color" value="${role.color || '#aaaaaa'}" style="width:50px;height:30px;border:none;cursor:pointer">
<label class="settings-label" style="margin-top:8px;">Role Icon</label>
<div class="role-icon-upload-row">
${role.icon ? `<img class="role-icon-preview" src="${this._escapeHtml(role.icon)}" alt="icon">` : '<div class="role-icon-preview" style="display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--text-muted)">None</div>'}
<input type="file" id="role-icon-file" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none">
<button class="btn-sm" id="role-icon-upload-btn" type="button">Upload</button>
${role.icon ? '<button class="btn-sm danger" id="role-icon-remove-btn" type="button">Remove</button>' : ''}
</div>
<small class="muted-text" style="font-size:11px;">Icon shown next to role name (auto-resized to 16×16). Max 512KB.</small>
<label class="toggle-row" style="margin-top:12px;">
<span>${t('settings.admin.role_form.auto_assign')}</span>
<input type="checkbox" id="role-edit-auto-assign" ${role.auto_assign ? 'checked' : ''}>
@ -2494,6 +2723,48 @@ _renderRoleDetail() {
// Toggle channel access panel visibility
const linkCheckbox = document.getElementById('role-edit-link-channel-access');
const accessPanel = document.getElementById('role-channel-access-panel');
// Role icon upload/remove
this._pendingRoleIcon = undefined;
const iconFileInput = document.getElementById('role-icon-file');
document.getElementById('role-icon-upload-btn')?.addEventListener('click', () => iconFileInput.click());
iconFileInput?.addEventListener('change', async () => {
const file = iconFileInput.files[0];
if (!file) return;
if (file.size > 512 * 1024) { this._showToast('Icon must be under 512KB', 'error'); return; }
// Auto-resize to 16x16 on a canvas so any image size works
let uploadFile = file;
try {
const bmp = await createImageBitmap(file);
if (bmp.width !== 16 || bmp.height !== 16) {
const canvas = document.createElement('canvas');
canvas.width = 16; canvas.height = 16;
const ctx = canvas.getContext('2d');
ctx.drawImage(bmp, 0, 0, 16, 16);
bmp.close();
uploadFile = await new Promise(r => canvas.toBlob(r, 'image/png'));
} else { bmp.close(); }
} catch { /* fall through with original file */ }
const fd = new FormData();
fd.append('icon', uploadFile, 'role-icon.png');
try {
const res = await fetch('/api/upload-role-icon', { method: 'POST', headers: { 'Authorization': 'Bearer ' + this.token }, body: fd });
const data = await res.json();
if (data.error) { this._showToast(data.error, 'error'); return; }
this._pendingRoleIcon = data.path;
const preview = panel.querySelector('.role-icon-preview');
if (preview) { preview.outerHTML = `<img class="role-icon-preview" src="${this._escapeHtml(data.path)}" alt="icon">`; }
this._showToast('Icon uploaded — save role to apply', 'success');
} catch { this._showToast('Upload failed', 'error'); }
});
document.getElementById('role-icon-remove-btn')?.addEventListener('click', () => {
this._pendingRoleIcon = null;
const preview = panel.querySelector('.role-icon-preview');
if (preview) { preview.outerHTML = '<div class="role-icon-preview" style="display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--text-muted)">None</div>'; }
const removeBtn = document.getElementById('role-icon-remove-btn');
if (removeBtn) removeBtn.remove();
this._showToast('Icon removed — save role to apply', 'success');
});
linkCheckbox.addEventListener('change', () => {
accessPanel.style.display = linkCheckbox.checked ? 'block' : 'none';
if (linkCheckbox.checked) this._loadRoleChannelAccess(role.id);
@ -2536,6 +2807,7 @@ _renderRoleDetail() {
name: document.getElementById('role-edit-name').value.trim(),
level: parseInt(document.getElementById('role-edit-level').value, 10),
color: document.getElementById('role-edit-color').value,
icon: this._pendingRoleIcon !== undefined ? this._pendingRoleIcon : role.icon,
autoAssign: document.getElementById('role-edit-auto-assign').checked,
linkChannelAccess: linkEnabled,
permissions: perms

View file

@ -12,6 +12,8 @@ async switchChannel(code) {
this.currentChannel = code;
this._coupledToBottom = true;
const jumpBtn = document.getElementById('jump-to-bottom');
if (jumpBtn) jumpBtn.classList.remove('visible');
const channel = this.channels.find(c => c.code === code);
const isDm = channel && channel.is_dm;
const displayName = isDm && channel.dm_target
@ -63,6 +65,8 @@ async switchChannel(code) {
}
document.getElementById('search-toggle-btn').style.display = '';
document.getElementById('pinned-toggle-btn').style.display = '';
// Auto-close pinned panel on channel switch so stale pins don't linger
document.getElementById('pinned-panel').style.display = 'none';
// Show "Select messages" button for admins/mods on non-DM channels
const moveSelectBtn = document.getElementById('move-select-btn');
@ -80,7 +84,9 @@ async switchChannel(code) {
const msgInputArea = document.getElementById('message-input-area');
const _textOff = channel && channel.text_enabled === 0;
const _mediaOff = channel && channel.media_enabled === 0;
if (msgInputArea) msgInputArea.style.display = (_textOff && _mediaOff) ? 'none' : '';
// Read-only: hide input unless user is admin or has read_only_override permission
const _isReadOnly = channel && channel.read_only === 1 && !this.user?.isAdmin && !this._hasPerm('read_only_override');
if (msgInputArea) msgInputArea.style.display = (_isReadOnly || (_textOff && _mediaOff)) ? 'none' : '';
// Text-only elements
const _msgInput = document.getElementById('message-input');
const _sendBtn = document.getElementById('send-btn');
@ -133,6 +139,7 @@ async switchChannel(code) {
this.socket.emit('get-channel-members', { code });
this.socket.emit('request-voice-users', { code });
this._clearReply();
this._closeThread();
// Auto-focus the message input for quick typing
const msgInput = document.getElementById('message-input');
@ -166,14 +173,15 @@ _updateTopicBar(topic) {
if (canEdit) {
bar.textContent = t('channels.topic_placeholder');
bar.style.display = 'block';
bar.style.opacity = '0.4';
bar.style.opacity = '';
bar.style.color = 'var(--text-muted)';
bar.style.cursor = 'pointer';
bar.onclick = () => this._editTopic();
} else {
bar.style.display = 'none';
}
}
if (topic) bar.style.opacity = '1';
if (topic) { bar.style.opacity = '1'; bar.style.color = ''; }
},
async _editTopic() {
@ -253,6 +261,9 @@ _openChannelCtxMenu(code, btnEl) {
if (cfnPanel) cfnPanel.style.display = 'none';
const cfnArrow = menu.querySelector('[data-action="channel-functions"] .cfn-arrow');
if (cfnArrow) cfnArrow.textContent = '▶';
// Show/hide "Mark as Read" based on unread count
const markReadBtn = menu.querySelector('[data-action="mark-read"]');
if (markReadBtn) markReadBtn.style.display = (this.unreadCounts[code] > 0) ? '' : 'none';
// Show "Create Sub-channel" for mods OR users with create_channel / manage_sub_channels perm
const ch = this.channels.find(c => c.code === code);
const createSubBtn = menu.querySelector('[data-action="create-sub-channel"]');
@ -337,6 +348,9 @@ _updateChannelFunctionsPanel(ch) {
this._setCfnBadge('streams', ch.streams_enabled !== 0, ch.streams_enabled !== 0 ? 'ON' : 'OFF');
this._setCfnBadge('music', ch.music_enabled !== 0, ch.music_enabled !== 0 ? 'ON' : 'OFF');
this._setCfnBadge('media', ch.media_enabled !== 0, ch.media_enabled !== 0 ? 'ON' : 'OFF');
// Read-only toggle
const isReadOnly = ch.read_only === 1;
this._setCfnBadge('read-only', isReadOnly, isReadOnly ? 'ON' : 'OFF');
const interval = ch.slow_mode_interval || 0;
this._setCfnBadge('slow-mode', interval > 0, interval > 0 ? `${interval}s` : 'OFF');
this._setCfnBadge('cleanup-exempt', ch.cleanup_exempt === 1, ch.cleanup_exempt === 1 ? 'ON' : 'OFF');
@ -399,6 +413,16 @@ _initDmContextMenu() {
this._dmCtxMenuEl = document.getElementById('dm-ctx-menu');
this._dmCtxMenuCode = null;
// Mark DM as read
document.querySelector('[data-action="dm-mark-read"]')?.addEventListener('click', () => {
const code = this._dmCtxMenuCode;
if (!code) return;
this._closeDmCtxMenu();
this.unreadCounts[code] = 0;
this._updateBadge(code);
this.socket.emit('mark-read-channel', { code });
});
// Mute DM
document.querySelector('[data-action="dm-mute"]')?.addEventListener('click', () => {
const code = this._dmCtxMenuCode;
@ -411,6 +435,14 @@ _initDmContextMenu() {
localStorage.setItem('haven_muted_channels', JSON.stringify(muted));
});
// Copy DM link
document.querySelector('[data-action="dm-copy-link"]')?.addEventListener('click', () => {
const code = this._dmCtxMenuCode;
if (!code) return;
this._closeDmCtxMenu();
this._copyChannelLink(code);
});
// Delete DM
document.querySelector('[data-action="dm-delete"]')?.addEventListener('click', () => {
const code = this._dmCtxMenuCode;
@ -438,6 +470,10 @@ _openDmCtxMenu(code, anchorEl, mouseEvent) {
const muteBtn = menu.querySelector('[data-action="dm-mute"]');
if (muteBtn) muteBtn.textContent = muted.includes(code) ? `🔕 ${t('channels.unmute_dm')}` : `🔔 ${t('channels.mute_dm')}`;
// Show/hide "Mark as Read" based on unread count
const markReadBtn = menu.querySelector('[data-action="dm-mark-read"]');
if (markReadBtn) markReadBtn.style.display = (this.unreadCounts[code] > 0) ? '' : 'none';
// Position
if (mouseEvent) {
menu.style.top = mouseEvent.clientY + 'px';
@ -749,8 +785,13 @@ _renderOrganizeList() {
let displayList = [...(this._organizeList || [])];
// Collect unique tags (including __untagged__ as a sortable entry)
const realTags = [...new Set(displayList.filter(c => c.category).map(c => c.category))];
// Collect unique tags (case-insensitive dedup, keep first-seen casing)
const _orgTagMap = new Map();
displayList.filter(c => c.category).forEach(c => {
const key = c.category.toLowerCase();
if (!_orgTagMap.has(key)) _orgTagMap.set(key, c.category);
});
const realTags = [..._orgTagMap.values()];
const hasUntagged = displayList.some(c => !c.category);
const hasTags = realTags.length > 0;
// Build the full ordered keys list: real tags + __untagged__ (if applicable)
@ -817,7 +858,8 @@ _renderOrganizeList() {
}
} else {
const tagSort = this._organizeTagSorts[key] || globalSort;
const tagItems = sortGroup(displayList.filter(c => c.category === key), tagSort);
const keyLower = key.toLowerCase();
const tagItems = sortGroup(displayList.filter(c => c.category && c.category.toLowerCase() === keyLower), tagSort);
grouped.push({ tag: key, items: tagItems, sort: tagSort });
}
}
@ -949,9 +991,9 @@ _getOrganizeVisualGroup(ch) {
const tagKey = ch.category || '__untagged__';
const effectiveSort = this._organizeTagSorts[tagKey] || globalSort;
// Collect channels in the same tag group
// Collect channels in the same tag group (case-insensitive)
const group = ch.category
? this._organizeList.filter(c => c.category === ch.category)
? this._organizeList.filter(c => c.category && c.category.toLowerCase() === ch.category.toLowerCase())
: this._organizeList.filter(c => !c.category);
// Sort by effective mode (mirrors _renderOrganizeList's sortGroup)
@ -979,7 +1021,7 @@ _moveCategoryInOrder(direction) {
// Build full ordered keys (real tags + __untagged__) from channel data
const displayList = [...(this._organizeList || [])];
const realTags = [...new Set(displayList.filter(c => c.category).map(c => c.category))];
const realTags = [...new Map(displayList.filter(c => c.category).map(c => [c.category.toLowerCase(), c.category])).values()];
const hasUntagged = displayList.some(c => !c.category);
const allKeys = [...realTags];
if (hasUntagged) allKeys.push('__untagged__');
@ -1230,7 +1272,7 @@ _renderChannels() {
const tagGroup = (a, b) => {
const tagA = a.category || '';
const tagB = b.category || '';
if (tagA !== tagB) {
if (tagA.toLowerCase() !== tagB.toLowerCase()) {
const keyA = tagA || '__untagged__';
const keyB = tagB || '__untagged__';
if (catSort === 'manual') {
@ -1312,7 +1354,7 @@ _renderChannels() {
const tagGroup = (a, b) => {
const tagA = a.category || '';
const tagB = b.category || '';
if (tagA !== tagB) {
if (tagA.toLowerCase() !== tagB.toLowerCase()) {
const keyA = tagA || '__untagged__';
const keyB = tagB || '__untagged__';
if (serverCatSort === 'manual') {
@ -1486,10 +1528,14 @@ _renderChannels() {
if (dp) dp.style.flex = '1 1 0';
}
// ── Render channels grouped by category ──
// ── Render channels grouped by category (case-insensitive) ──
const categories = new Map();
const _catCanonical = new Map(); // lowercase -> first-seen casing
parentChannels.forEach(ch => {
const cat = ch.category || '';
const raw = ch.category || '';
const key = raw.toLowerCase();
if (!_catCanonical.has(key)) _catCanonical.set(key, raw);
const cat = _catCanonical.get(key);
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat).push(ch);
});
@ -1575,7 +1621,7 @@ _renderChannels() {
const subHasTags = subs.some(s => s.category);
let lastSubTag = undefined;
subs.forEach(sub => {
if (subHasTags && sub.category !== lastSubTag) {
if (subHasTags && (lastSubTag === undefined || (sub.category || '').toLowerCase() !== (lastSubTag || '').toLowerCase())) {
const tagName = sub.category || 'Untagged';
const tagKey = `haven_subtag_collapsed_${ch.code}_${tagName}`;
const isTagCollapsed = localStorage.getItem(tagKey) === 'true';
@ -1942,8 +1988,13 @@ _updateDesktopBadge() {
* Browser: uses Notification API only when push subscription is NOT active
* to avoid duplicate notifications (server-side push handles the rest).
*/
_fireNativeNotification(message, channelCode) {
if (!this.notifications.enabled) return;
_fireNativeNotification(message, channelCode, opts) {
// Check per-type notification toggles
const n = this.notifications;
if (opts && opts.isMention && n.mentionsEnabled) { /* allowed */ }
else if (opts && opts.isReply && n.repliesEnabled) { /* allowed */ }
else if (opts && opts.isDm && n.dmEnabled) { /* allowed */ }
else if (!n.enabled) return;
// Don't notify for own messages
if (message.user_id === this.user?.id) return;
@ -1951,9 +2002,12 @@ _fireNativeNotification(message, channelCode) {
const channel = this.channels?.find(c => c.code === channelCode);
const channelLabel = channel?.is_dm ? 'DM' : `#${channel?.name || channelCode}`;
const title = `${sender} in ${channelLabel}`;
const body = (message.content || '').length > 120
? message.content.slice(0, 117) + '...'
: (message.content || 'Sent an attachment');
let rawContent = message.content || '';
// Detect E2E encrypted envelope — show generic text instead of ciphertext
try { const p = JSON.parse(rawContent); if (p && p.v && p.ct) rawContent = ''; } catch { /* not JSON */ }
const body = rawContent.length > 120
? rawContent.slice(0, 117) + '...'
: (rawContent || 'Sent a message');
// Desktop app: always use native Electron notifications
if (window.havenDesktop?.notify) {

View file

@ -214,8 +214,12 @@ _setupNotifications() {
const msgSound = document.getElementById('notif-msg-sound');
const mentionVolume = document.getElementById('notif-mention-volume');
const mentionSound = document.getElementById('notif-mention-sound');
const replyVolume = document.getElementById('notif-reply-volume');
const replySound = document.getElementById('notif-reply-sound');
const sentSound = document.getElementById('notif-sent-sound');
const joinVolume = document.getElementById('notif-join-volume');
const joinSound = document.getElementById('notif-join-sound');
const leaveVolume = document.getElementById('notif-leave-volume');
const leaveSound = document.getElementById('notif-leave-sound');
toggle.checked = this.notifications.enabled;
@ -224,9 +228,21 @@ _setupNotifications() {
if (sentSound) sentSound.value = this.notifications.sounds.sent;
mentionVolume.value = this.notifications.mentionVolume * 100;
mentionSound.value = this.notifications.sounds.mention;
if (replyVolume) replyVolume.value = this.notifications.replyVolume * 100;
if (replySound) replySound.value = this.notifications.sounds.reply;
if (joinVolume) joinVolume.value = this.notifications.joinVolume * 100;
if (joinSound) joinSound.value = this.notifications.sounds.join;
if (leaveVolume) leaveVolume.value = this.notifications.leaveVolume * 100;
if (leaveSound) leaveSound.value = this.notifications.sounds.leave;
// Per-type toggles
const mentionsToggle = document.getElementById('notif-mentions-enabled');
const repliesToggle = document.getElementById('notif-replies-enabled');
const dmToggle = document.getElementById('notif-dm-enabled');
if (mentionsToggle) { mentionsToggle.checked = this.notifications.mentionsEnabled; mentionsToggle.addEventListener('change', () => { this.notifications.mentionsEnabled = mentionsToggle.checked; this.notifications._savePref('haven_notif_mentions_enabled', mentionsToggle.checked); }); }
if (repliesToggle) { repliesToggle.checked = this.notifications.repliesEnabled; repliesToggle.addEventListener('change', () => { this.notifications.repliesEnabled = repliesToggle.checked; this.notifications._savePref('haven_notif_replies_enabled', repliesToggle.checked); }); }
if (dmToggle) { dmToggle.checked = this.notifications.dmEnabled; dmToggle.addEventListener('change', () => { this.notifications.dmEnabled = dmToggle.checked; this.notifications._savePref('haven_notif_dm_enabled', dmToggle.checked); }); }
toggle.addEventListener('change', () => {
this.notifications.setEnabled(toggle.checked);
});
@ -256,6 +272,25 @@ _setupNotifications() {
this.notifications.play('mention'); // Preview the selected sound
});
if (replyVolume) {
replyVolume.addEventListener('input', () => {
this.notifications.setReplyVolume(replyVolume.value / 100);
});
}
if (replySound) {
replySound.addEventListener('change', () => {
this.notifications.setSound('reply', replySound.value);
this.notifications.play('reply');
});
}
if (joinVolume) {
joinVolume.addEventListener('input', () => {
this.notifications.setJoinVolume(joinVolume.value / 100);
});
}
if (joinSound) {
joinSound.addEventListener('change', () => {
this.notifications.setSound('join', joinSound.value);
@ -263,6 +298,12 @@ _setupNotifications() {
});
}
if (leaveVolume) {
leaveVolume.addEventListener('input', () => {
this.notifications.setLeaveVolume(leaveVolume.value / 100);
});
}
if (leaveSound) {
leaveSound.addEventListener('change', () => {
this.notifications.setSound('leave', leaveSound.value);
@ -306,6 +347,93 @@ _setupNotifications() {
}
});
}
// Up arrow edits last message (on by default)
const upArrowEditToggle = document.getElementById('up-arrow-edit');
if (upArrowEditToggle) {
upArrowEditToggle.checked = localStorage.getItem('haven_up_arrow_edit') !== 'false';
upArrowEditToggle.addEventListener('change', () => {
localStorage.setItem('haven_up_arrow_edit', String(upArrowEditToggle.checked));
});
}
// Show status bar (opt-in — hidden by default, but Desktop always shows its own footer)
const showStatusBarToggle = document.getElementById('show-status-bar');
const statusBarToggleTab = document.getElementById('status-bar-toggle');
const _hasDesktopFooter = !!document.getElementById('haven-desktop-footer');
if (showStatusBarToggle) {
showStatusBarToggle.checked = localStorage.getItem('haven_show_statusbar') === 'true';
const applyStatusBar = () => {
// On Desktop the preload's own footer is always visible; don't touch it
if (_hasDesktopFooter) return;
const show = showStatusBarToggle.checked;
if (show) {
document.documentElement.removeAttribute('data-hide-statusbar');
const sb = document.getElementById('status-bar');
if (sb) sb.style.setProperty('display', 'flex', 'important');
} else {
document.documentElement.setAttribute('data-hide-statusbar', '1');
}
};
showStatusBarToggle.addEventListener('change', () => {
localStorage.setItem('haven_show_statusbar', String(showStatusBarToggle.checked));
applyStatusBar();
});
applyStatusBar();
}
// Toggle tab (visible when bar is hidden) — click to show bar
if (statusBarToggleTab) {
statusBarToggleTab.addEventListener('click', () => {
if (showStatusBarToggle) {
showStatusBarToggle.checked = true;
showStatusBarToggle.dispatchEvent(new Event('change'));
} else {
// Fallback: toggle directly
document.documentElement.removeAttribute('data-hide-statusbar');
const sb = document.getElementById('status-bar');
if (sb) sb.style.setProperty('display', 'flex', 'important');
localStorage.setItem('haven_show_statusbar', 'true');
}
});
}
// ── Server URL in status bar (copyable, privacy toggle) ──
const statusUrlEl = document.getElementById('status-url-text');
const statusUrlToggle = document.getElementById('status-url-toggle');
if (statusUrlEl && statusUrlToggle) {
const origin = window.location.origin;
let urlVisible = localStorage.getItem('haven_statusbar_show_url') !== 'false';
const applyUrlVis = () => {
if (urlVisible) {
statusUrlEl.textContent = origin;
statusUrlEl.classList.remove('url-hidden');
statusUrlToggle.textContent = '👁';
statusUrlToggle.title = 'Hide server address';
} else {
statusUrlEl.textContent = '••••••••';
statusUrlEl.classList.add('url-hidden');
statusUrlToggle.textContent = '👁\u200d🗨';
statusUrlToggle.title = 'Show server address';
}
};
applyUrlVis();
statusUrlToggle.addEventListener('click', () => {
urlVisible = !urlVisible;
localStorage.setItem('haven_statusbar_show_url', String(urlVisible));
applyUrlVis();
});
// Click to copy — works even when URL is hidden
statusUrlEl.addEventListener('click', () => {
navigator.clipboard.writeText(origin).then(() => {
const orig = statusUrlEl.textContent;
statusUrlEl.textContent = 'Copied!';
setTimeout(() => { statusUrlEl.textContent = urlVisible ? origin : '••••••••'; }, 1500);
}).catch(() => {});
});
}
},
// ── Push Notifications (Web Push API) ──────────────────
@ -865,6 +993,27 @@ _startStatusBar() {
// CSS responsive breakpoints or DPI-scaled viewport width.
const isDesktop = !!(window.havenDesktop?.isDesktopApp ||
navigator.userAgent.includes('Electron'));
const _forceWebStatusBar = () => {
const sb = document.getElementById('status-bar');
if (!sb) return;
sb.style.setProperty('display', 'flex', 'important');
// Verify the bar is inside the visible viewport. If clipped by
// Electron BrowserView (100dvh mismatch), fall back to fixed positioning.
requestAnimationFrame(() => {
const rect = sb.getBoundingClientRect();
if (rect.height === 0 || rect.bottom > window.innerHeight + 2) {
sb.style.setProperty('position', 'fixed', 'important');
sb.style.setProperty('bottom', '0', 'important');
sb.style.setProperty('left', '0', 'important');
sb.style.setProperty('right', '0', 'important');
sb.style.setProperty('z-index', '50', 'important');
const appBody = document.getElementById('app-body');
if (appBody) appBody.style.paddingBottom = sb.offsetHeight + 'px';
}
});
};
if (isDesktop) {
// Belt-and-suspenders: ensure the CSS attribute is present (preload
// sets this on DOMContentLoaded, but reinforce here in case of timing)
@ -872,25 +1021,26 @@ _startStatusBar() {
// 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');
if (!hasDesktopFooter) {
_forceWebStatusBar();
}
// Delayed fallback: if after 600 ms neither footer is visible (e.g. old
// Desktop build whose preload hides the web bar but doesn't create its
// own), force-show the web status bar regardless.
setTimeout(() => {
const hdf = document.getElementById('haven-desktop-footer');
const sb = document.getElementById('status-bar');
if (!hdf && sb && getComputedStyle(sb).display === 'none') {
_forceWebStatusBar();
}
}, 600);
} else {
// Browser / mobile: respect the user's opt-in preference (default hidden).
// The settings toggle in _initSettings applies the attribute + display;
// here we just honour it in case _startStatusBar runs first.
const sb = document.getElementById('status-bar');
if (sb && !hasDesktopFooter) {
if (sb && localStorage.getItem('haven_show_statusbar') === 'true') {
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
// mismatch), fall back to fixed positioning so the user always sees it.
requestAnimationFrame(() => {
const rect = sb.getBoundingClientRect();
if (rect.height === 0 || rect.bottom > window.innerHeight + 2) {
sb.style.setProperty('position', 'fixed', 'important');
sb.style.setProperty('bottom', '0', 'important');
sb.style.setProperty('left', '0', 'important');
sb.style.setProperty('right', '0', 'important');
sb.style.setProperty('z-index', '50', 'important');
// Prevent content underneath from being hidden behind the bar
const appBody = document.getElementById('app-body');
if (appBody) appBody.style.paddingBottom = sb.offsetHeight + 'px';
}
});
}
}
this._updateClock();

View file

@ -1571,6 +1571,165 @@ _applyImageMode(mode) {
document.body.classList.toggle('image-mode-full', mode === 'full');
},
// ── Role Display Picker ──
_setupRoleDisplayPicker() {
const picker = document.getElementById('role-display-picker');
if (!picker) return;
const saved = localStorage.getItem('haven-role-display') || 'colored-name';
document.documentElement.dataset.roleDisplay = saved;
picker.querySelectorAll('[data-roledisplay]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.roledisplay === saved);
});
picker.addEventListener('click', (e) => {
const btn = e.target.closest('[data-roledisplay]');
if (!btn) return;
const mode = btn.dataset.roledisplay;
document.documentElement.dataset.roleDisplay = mode;
localStorage.setItem('haven-role-display', mode);
picker.querySelectorAll('[data-roledisplay]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Re-render member list to reflect the change
if (this._updateUsers) this._updateUsers();
});
},
// ── Toolbar Icon Style Picker ──
_setupToolbarIconPicker() {
const picker = document.getElementById('toolbar-icon-picker');
const slotsInput = document.getElementById('toolbar-visible-slots');
const slotsValue = document.getElementById('toolbar-visible-slots-value');
const orderList = document.getElementById('toolbar-order-list');
const resetBtn = document.getElementById('toolbar-order-reset-btn');
if (!picker) return;
const defaultOrder = ['react', 'reply', 'quote', 'thread', 'pin', 'archive', 'edit', 'delete'];
const actionLabels = {
react: 'React',
reply: 'Reply',
quote: 'Quote',
thread: 'Thread',
pin: 'Pin / Unpin',
archive: 'Protect / Unprotect',
edit: 'Edit',
delete: 'Delete'
};
const normalizeOrder = (value) => {
const arr = Array.isArray(value) ? value : [];
const clean = [];
arr.forEach((k) => {
if (defaultOrder.includes(k) && !clean.includes(k)) clean.push(k);
});
defaultOrder.forEach((k) => {
if (!clean.includes(k)) clean.push(k);
});
return clean;
};
const refreshCurrentMessages = () => {
if (this.currentChannel && this.socket?.connected) {
this.socket.emit('get-messages', { code: this.currentChannel });
}
};
const savedMode = localStorage.getItem('haven-toolbar-icons') || 'mono';
const normalizedMode = savedMode === 'color' ? 'emoji' : savedMode;
document.documentElement.dataset.toolbaricons = normalizedMode;
picker.querySelectorAll('[data-toolbaricons]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.toolbaricons === normalizedMode);
});
let savedSlots = parseInt(localStorage.getItem('haven-toolbar-visible-slots') || '3', 10);
if (!Number.isFinite(savedSlots)) savedSlots = 3;
savedSlots = Math.max(1, Math.min(7, savedSlots));
localStorage.setItem('haven-toolbar-visible-slots', String(savedSlots));
if (slotsInput) slotsInput.value = String(savedSlots);
if (slotsValue) slotsValue.textContent = String(savedSlots);
let savedOrder;
try {
savedOrder = JSON.parse(localStorage.getItem('haven-toolbar-order') || '[]');
} catch {
savedOrder = [];
}
let currentOrder = normalizeOrder(savedOrder);
localStorage.setItem('haven-toolbar-order', JSON.stringify(currentOrder));
const renderOrderList = () => {
if (!orderList) return;
orderList.innerHTML = '';
currentOrder.forEach((key, index) => {
const row = document.createElement('div');
row.className = 'toolbar-order-item';
row.innerHTML = `
<span class="toolbar-order-item-label">${actionLabels[key] || key}</span>
<div class="toolbar-order-item-controls">
<button type="button" class="toolbar-order-move" data-dir="up" data-key="${key}" ${index === 0 ? 'disabled' : ''} title="Move up"></button>
<button type="button" class="toolbar-order-move" data-dir="down" data-key="${key}" ${index === currentOrder.length - 1 ? 'disabled' : ''} title="Move down"></button>
</div>
`;
orderList.appendChild(row);
});
};
renderOrderList();
picker.addEventListener('click', (e) => {
const btn = e.target.closest('[data-toolbaricons]');
if (!btn) return;
const mode = btn.dataset.toolbaricons;
document.documentElement.dataset.toolbaricons = mode;
localStorage.setItem('haven-toolbar-icons', mode);
picker.querySelectorAll('[data-toolbaricons]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
refreshCurrentMessages();
});
if (slotsInput) {
slotsInput.addEventListener('input', () => {
if (slotsValue) slotsValue.textContent = slotsInput.value;
});
slotsInput.addEventListener('change', () => {
const value = Math.max(1, Math.min(7, parseInt(slotsInput.value || '3', 10) || 3));
localStorage.setItem('haven-toolbar-visible-slots', String(value));
if (slotsValue) slotsValue.textContent = String(value);
refreshCurrentMessages();
});
}
if (orderList) {
orderList.addEventListener('click', (e) => {
const btn = e.target.closest('.toolbar-order-move');
if (!btn) return;
const key = btn.dataset.key;
const dir = btn.dataset.dir;
const idx = currentOrder.indexOf(key);
if (idx < 0) return;
const swapWith = dir === 'up' ? idx - 1 : idx + 1;
if (swapWith < 0 || swapWith >= currentOrder.length) return;
const next = currentOrder.slice();
[next[idx], next[swapWith]] = [next[swapWith], next[idx]];
currentOrder = next;
localStorage.setItem('haven-toolbar-order', JSON.stringify(currentOrder));
renderOrderList();
refreshCurrentMessages();
});
}
if (resetBtn) {
resetBtn.addEventListener('click', () => {
currentOrder = defaultOrder.slice();
localStorage.setItem('haven-toolbar-order', JSON.stringify(currentOrder));
renderOrderList();
refreshCurrentMessages();
});
}
},
// ── Image Lightbox ──
_setupLightbox() {
@ -1581,9 +1740,18 @@ _setupLightbox() {
if (e.target === lb) this._closeLightbox();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && lb.style.display !== 'none') this._closeLightbox();
if (lb.style.display === 'none') return;
if (e.key === 'Escape') this._closeLightbox();
if (e.key === 'ArrowLeft') this._lightboxNavigate(-1);
if (e.key === 'ArrowRight') this._lightboxNavigate(1);
});
// Nav button clicks
const prevBtn = document.getElementById('lightbox-prev');
const nextBtn = document.getElementById('lightbox-next');
if (prevBtn) prevBtn.addEventListener('click', (e) => { e.stopPropagation(); this._lightboxNavigate(-1); });
if (nextBtn) nextBtn.addEventListener('click', (e) => { e.stopPropagation(); this._lightboxNavigate(1); });
// Custom context menu for lightbox image (Save, Copy, Open)
const lbImg = document.getElementById('lightbox-img');
if (lbImg) {
@ -1595,12 +1763,46 @@ _setupLightbox() {
}
},
_getLightboxImages() {
const msgs = document.getElementById('messages');
if (!msgs) return [];
return Array.from(msgs.querySelectorAll('.chat-image')).map(img => img.src);
},
_lightboxNavigate(dir) {
const imgs = this._getLightboxImages();
const lbImg = document.getElementById('lightbox-img');
if (!lbImg || imgs.length < 2) return;
const curIdx = imgs.indexOf(lbImg.src);
if (curIdx < 0) return;
const newIdx = curIdx + dir;
if (newIdx < 0 || newIdx >= imgs.length) return;
lbImg.src = imgs[newIdx];
this._updateLightboxNav();
},
_updateLightboxNav() {
const imgs = this._getLightboxImages();
const lbImg = document.getElementById('lightbox-img');
const prevBtn = document.getElementById('lightbox-prev');
const nextBtn = document.getElementById('lightbox-next');
if (!lbImg || !prevBtn || !nextBtn) return;
const curIdx = imgs.indexOf(lbImg.src);
prevBtn.disabled = curIdx <= 0;
nextBtn.disabled = curIdx < 0 || curIdx >= imgs.length - 1;
// Hide nav if only one image
const showNav = imgs.length > 1;
prevBtn.style.display = showNav ? '' : 'none';
nextBtn.style.display = showNav ? '' : 'none';
},
_openLightbox(src) {
const lb = document.getElementById('image-lightbox');
const img = document.getElementById('lightbox-img');
if (!lb || !img) return;
img.src = src;
lb.style.display = 'flex';
this._updateLightboxNav();
},
_closeLightbox() {
@ -1630,7 +1832,8 @@ _setupModalExpand() {
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;
modal.classList.contains('desktop-promo') ||
modal.classList.contains('donors-modal-box')) return;
// Find the header container — either .settings-header / .activities-header or the first h3
let headerContainer = modal.querySelector('.settings-header, .activities-header');
@ -1721,11 +1924,31 @@ _showImageContextMenu(e, src) {
a.remove();
} else if (action === 'copy') {
try {
const resp = await fetch(src);
const blob = await resp.blob();
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
// Always convert via canvas to guarantee a valid image/png blob
const img = new Image();
img.crossOrigin = 'anonymous';
const loaded = new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
img.src = src;
await loaded;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
const pngBlob = await new Promise((res, rej) => {
canvas.toBlob(b => b ? res(b) : rej(new Error('canvas.toBlob returned null')), 'image/png');
});
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard && navigator.clipboard.write) {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
} else {
// Fallback: copy data URL as text
const reader = new FileReader();
const dataUrl = await new Promise(r => { reader.onload = () => r(reader.result); reader.readAsDataURL(pngBlob); });
await navigator.clipboard.writeText(dataUrl);
}
this._showToast('Image copied to clipboard', 'success');
} catch {
} catch (err) {
console.error('[Haven] Copy image failed:', err);
this._showToast('Failed to copy image', 'error');
}
} else if (action === 'open') {

View file

@ -76,6 +76,14 @@ async _sendMessage() {
this._hideSlashDropdown();
return;
}
if (cmd === 'poll') {
input.value = '';
input.style.height = 'auto';
this._hideMentionDropdown();
this._hideSlashDropdown();
this._openPollModal();
return;
}
}
}
@ -160,7 +168,20 @@ async _sendMessage() {
}
},
_renderMessages(messages) {
_jumpToMessage(msgId) {
const existing = document.querySelector(`#messages [data-msg-id="${msgId}"]`);
if (existing) {
existing.scrollIntoView({ behavior: 'smooth', block: 'center' });
existing.classList.add('highlight-flash');
setTimeout(() => existing.classList.remove('highlight-flash'), 2000);
return;
}
// Message not in DOM — fetch messages around it
this._jumpTargetId = msgId;
this.socket.emit('get-messages', { code: this.currentChannel, around: msgId });
},
_renderMessages(messages, lastReadMessageId) {
const container = document.getElementById('messages');
container.innerHTML = '';
// Only render the last MAX_DOM_MESSAGES to prevent OOM on large histories
@ -168,18 +189,76 @@ _renderMessages(messages) {
const start = messages.length > MAX_DOM_MESSAGES ? messages.length - MAX_DOM_MESSAGES : 0;
// Use DocumentFragment to batch all DOM inserts into a single reflow
const frag = document.createDocumentFragment();
// Determine where to insert the "NEW MESSAGES" divider.
// Only show it when there are actually unread messages and the last message
// isn't already "read" (i.e. the user isn't fully caught up).
let newMsgDividerInserted = false;
const showDivider = lastReadMessageId && messages.length > 0
&& messages[messages.length - 1].id > lastReadMessageId
// Don't show divider if ALL messages are unread (nothing before the line)
&& messages[start]?.id <= lastReadMessageId;
for (let i = start; i < messages.length; i++) {
const prevMsg = i > start ? messages[i - 1] : null;
// Insert "NEW MESSAGES" divider before the first unread message
if (showDivider && !newMsgDividerInserted && messages[i].id > lastReadMessageId
&& messages[i].user_id !== this.user?.id) {
const divider = document.createElement('div');
divider.className = 'new-messages-divider';
divider.id = 'new-messages-divider';
divider.innerHTML = '<span>NEW MESSAGES</span>';
frag.appendChild(divider);
newMsgDividerInserted = true;
}
frag.appendChild(this._createMessageEl(messages[i], prevMsg));
}
container.appendChild(frag);
this._scrollToBottom(true);
// Re-scroll after images load, but only if user hasn't scrolled away
container.querySelectorAll('img').forEach(img => {
if (!img.complete) img.addEventListener('load', () => {
if (this._coupledToBottom) this._scrollToBottom(true);
}, { once: true });
});
const jumpId = this._jumpTargetId;
if (jumpId) {
// Jump-to-message mode: scroll to target instead of bottom
this._jumpTargetId = null;
this._coupledToBottom = false;
const scrollToTarget = () => {
const target = container.querySelector(`[data-msg-id="${jumpId}"]`);
if (target) {
target.scrollIntoView({ block: 'center' });
target.classList.add('highlight-flash');
setTimeout(() => target.classList.remove('highlight-flash'), 2000);
}
};
scrollToTarget();
requestAnimationFrame(scrollToTarget);
setTimeout(scrollToTarget, 300);
} else if (newMsgDividerInserted) {
// Scroll to the "NEW MESSAGES" divider so the user sees where they left off
this._coupledToBottom = false;
const scrollToDivider = () => {
const divider = document.getElementById('new-messages-divider');
if (divider) divider.scrollIntoView({ block: 'start' });
};
scrollToDivider();
requestAnimationFrame(scrollToDivider);
setTimeout(scrollToDivider, 300);
// Show jump-to-bottom button since we're not at the bottom
const jumpBtn = document.getElementById('jump-to-bottom');
if (jumpBtn) jumpBtn.classList.add('visible');
} else {
this._scrollToBottom(true);
// Re-scroll after images load, but only if user hasn't scrolled away
container.querySelectorAll('img').forEach(img => {
if (!img.complete) img.addEventListener('load', () => {
if (this._coupledToBottom) this._scrollToBottom(true);
}, { once: true });
});
// Deferred re-scroll: images, link previews, and E2E decryption can add
// height after the synchronous scrollToBottom above. Force a re-scroll
// after layout settles to prevent DMs from landing mid-history.
requestAnimationFrame(() => this._scrollToBottom(true));
setTimeout(() => { if (this._coupledToBottom) this._scrollToBottom(true); }, 300);
}
// Fetch link previews for all messages
this._fetchLinkPreviews(container);
this._setupVideos(container);
@ -452,33 +531,87 @@ _createMessageEl(msg, prevMsg) {
const reactionsHtml = this._renderReactions(msg.id, msg.reactions || []);
const pollHtml = msg.poll ? this._renderPollWidget(msg.id, msg.poll) : '';
const threadHtml = msg.thread ? this._renderThreadPreview(msg.id, msg.thread) : '';
const editedHtml = msg.edited_at ? `<span class="edited-tag" title="${t('app.messages.edited_at', { date: new Date(msg.edited_at).toLocaleString() })}">${t('app.messages.edited')}</span>` : '';
const pinnedTag = msg.pinned ? `<span class="pinned-tag" title="${t('app.messages.pinned')}">📌</span>` : '';
const archivedTag = msg.is_archived ? `<span class="archived-tag" title="${t('app.messages.protected')}">🛡️</span>` : '';
const e2eTag = msg._e2e ? `<span class="e2e-tag" title="${t('app.messages.e2e_encrypted')}">🔒</span>` : '';
// Build toolbar with context-aware buttons
let toolbarBtns = `<button data-action="react" title="${t('msg_toolbar.react')}">😀</button><button data-action="reply" title="${t('msg_toolbar.reply')}">↩️</button>`;
const iconPair = (emoji, monoSvg) => `<span class="tb-icon tb-icon-emoji" aria-hidden="true">${emoji}</span><span class="tb-icon tb-icon-mono" aria-hidden="true">${monoSvg}</span>`;
const iReact = iconPair('😀', '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke-width="1.8"></circle><path d="M8.5 14.5c1 1.2 2.2 1.8 3.5 1.8s2.5-.6 3.5-1.8" stroke-width="1.8" stroke-linecap="round"></path><circle cx="9.2" cy="10.2" r="1" fill="currentColor" stroke="none"></circle><circle cx="14.8" cy="10.2" r="1" fill="currentColor" stroke="none"></circle></svg>');
const iReply = iconPair('↩️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 8L4 12L10 16" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M20 12H5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iQuote = iconPair('💬', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 7H5v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M19 7h-4v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>');
const iThread = iconPair('🧵', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 9h8" stroke-width="1.8" stroke-linecap="round"></path><path d="M8 13h6" stroke-width="1.8" stroke-linecap="round"></path><path d="M6 6h12a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-8l-4 3v-3H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z" stroke-width="1.8" stroke-linejoin="round"></path></svg>');
const iPin = iconPair('📌', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4h8l-2 5v4l2 2H8l2-2V9L8 4z" stroke-width="1.8" stroke-linejoin="round"></path><path d="M12 15v5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iArchive = iconPair('🛡️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16v11H4z" stroke-width="1.8" stroke-linejoin="round"></path><path d="M9 11h6" stroke-width="1.8" stroke-linecap="round"></path><path d="M3 7l2-3h14l2 3" stroke-width="1.8" stroke-linejoin="round"></path></svg>');
const iEdit = iconPair('✏️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20l4.5-1 9-9-3.5-3.5-9 9L4 20z" stroke-width="1.8" stroke-linejoin="round"></path><path d="M13.5 6.5l3.5 3.5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iDelete = iconPair('🗑️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14" stroke-width="1.8" stroke-linecap="round"></path><path d="M9 7V5h6v2" stroke-width="1.8" stroke-linecap="round"></path><path d="M7 7l1 12h8l1-12" stroke-width="1.8" stroke-linejoin="round"></path></svg>');
const iLink = iconPair('🔗', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>');
const iMore = iconPair('⋯', '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="18" cy="12" r="1.6" fill="currentColor" stroke="none"></circle></svg>');
const toolbarActions = [
{ key: 'react', html: `<button data-action="react" title="${t('msg_toolbar.react')}">${iReact}</button>` },
{ key: 'reply', html: `<button data-action="reply" title="${t('msg_toolbar.reply')}">${iReply}</button>` },
{ key: 'quote', html: `<button data-action="quote" title="${t('msg_toolbar.quote')}">${iQuote}</button>` },
{ key: 'thread', html: `<button data-action="thread" title="Thread">${iThread}</button>` },
{ key: 'copy-link', html: `<button data-action="copy-link" title="${t('msg_toolbar.copy_link') || 'Copy link to message'}">${iLink}</button>` }
];
const canPin = this.user.isAdmin || this._canModerate();
const canArchive = this.user.isAdmin || this._hasPerm('archive_messages');
const canDelete = msg.user_id === this.user.id || this.user.isAdmin || this._canModerate();
if (canPin) {
toolbarBtns += msg.pinned
? `<button data-action="unpin" title="${t('msg_toolbar.unpin')}">📌</button>`
: `<button data-action="pin" title="${t('msg_toolbar.pin')}">📌</button>`;
toolbarActions.push({
key: 'pin',
html: msg.pinned
? `<button data-action="unpin" title="${t('msg_toolbar.unpin')}">${iPin}</button>`
: `<button data-action="pin" title="${t('msg_toolbar.pin')}">${iPin}</button>`
});
}
if (canArchive) {
toolbarBtns += msg.is_archived
? `<button data-action="unarchive" title="${t('app.messages.unprotect_btn')}">🛡️</button>`
: `<button data-action="archive" title="${t('app.messages.protect_btn')}">🛡️</button>`;
toolbarActions.push({
key: 'archive',
html: msg.is_archived
? `<button data-action="unarchive" title="${t('app.messages.unprotect_btn')}">${iArchive}</button>`
: `<button data-action="archive" title="${t('app.messages.protect_btn')}">${iArchive}</button>`
});
}
if (msg.user_id === this.user.id) {
toolbarBtns += `<button data-action="edit" title="${t('msg_toolbar.edit')}">✏️</button>`;
toolbarActions.push({ key: 'edit', html: `<button data-action="edit" title="${t('msg_toolbar.edit')}">${iEdit}</button>` });
}
if (canDelete) {
toolbarBtns += `<button data-action="delete" title="${t('msg_toolbar.delete')}">🗑️</button>`;
toolbarActions.push({ key: 'delete', html: `<button data-action="delete" title="${t('msg_toolbar.delete')}">${iDelete}</button>` });
}
const toolbarHtml = `<div class="msg-toolbar">${toolbarBtns}</div>`;
const defaultToolbarOrder = ['react', 'reply', 'quote', 'thread', 'copy-link', 'pin', 'archive', 'edit', 'delete'];
let savedToolbarOrder = [];
try {
savedToolbarOrder = JSON.parse(localStorage.getItem('haven-toolbar-order') || '[]');
} catch {
savedToolbarOrder = [];
}
const normalizedOrder = [];
savedToolbarOrder.forEach((key) => {
if (defaultToolbarOrder.includes(key) && !normalizedOrder.includes(key)) normalizedOrder.push(key);
});
defaultToolbarOrder.forEach((key) => {
if (!normalizedOrder.includes(key)) normalizedOrder.push(key);
});
const orderRank = new Map(normalizedOrder.map((key, index) => [key, index]));
toolbarActions.sort((a, b) => (orderRank.get(a.key) ?? 999) - (orderRank.get(b.key) ?? 999));
let visibleSlots = parseInt(localStorage.getItem('haven-toolbar-visible-slots') || '3', 10);
if (!Number.isFinite(visibleSlots)) visibleSlots = 3;
visibleSlots = Math.max(1, Math.min(7, visibleSlots));
const visibleActions = toolbarActions.slice(0, visibleSlots);
const overflowActions = toolbarActions.slice(visibleSlots);
const coreToolbarBtns = visibleActions.map(a => a.html).join('');
const overflowToolbarBtns = overflowActions.map(a => a.html).join('');
const moreMenuHtml = overflowActions.length
? `<div class="msg-toolbar-more"><button class="msg-toolbar-more-btn" type="button" aria-label="More actions">${iMore}</button><div class="msg-toolbar-overflow">${overflowToolbarBtns}</div></div>`
: '';
const toolbarHtml = `<div class="msg-toolbar"><div class="msg-toolbar-group">${coreToolbarBtns}</div>${moreMenuHtml}</div>`;
const replyHtml = msg.replyContext ? this._renderReplyBanner(msg.replyContext) : '';
if (isCompact) {
@ -500,6 +633,7 @@ _createMessageEl(msg, prevMsg) {
<div class="message-content">${pinnedTag}${archivedTag}${this._formatContent(msg.content)}${editedHtml}</div>
${pollHtml}
${reactionsHtml}
${threadHtml}
</div>
${e2eTag}
${toolbarHtml}
@ -536,6 +670,21 @@ _createMessageEl(msg, prevMsg) {
? `<span class="user-role-badge msg-role-badge" style="color:${this._safeColor(onlineUser.role.color, 'var(--text-muted)')}">${this._escapeHtml(onlineUser.role.name)}</span>`
: '';
// Role icon in chat
const showIconChat = this.serverSettings.role_icon_chat === 'true';
const iconAfterName = this.serverSettings.role_icon_after_name === 'true';
const msgRoleIcon = showIconChat && onlineUser && onlineUser.role && onlineUser.role.icon
? `<img class="role-icon" src="${this._escapeHtml(onlineUser.role.icon)}" alt="" title="${this._escapeHtml(onlineUser.role.name)}">`
: '';
const msgRoleIconBefore = msgRoleIcon && !iconAfterName ? msgRoleIcon : '';
const msgRoleIconAfter = msgRoleIcon && iconAfterName ? msgRoleIcon : '';
// Role color display mode: colored-name uses role color for the author name
const roleDisplayMode = localStorage.getItem('haven-role-display') || 'colored-name';
const authorColor = (roleDisplayMode === 'colored-name' && onlineUser && onlineUser.role && onlineUser.role.color)
? this._safeColor(onlineUser.role.color, color)
: color;
const botBadge = msg.imported_from === 'discord'
? '<span class="discord-badge">DISCORD</span>'
: msg.is_webhook ? '<span class="bot-badge">BOT</span>' : '';
@ -552,12 +701,14 @@ _createMessageEl(msg, prevMsg) {
if (msg._e2e) el.dataset.e2e = '1';
if (msg.poll && msg.poll.anonymous) el.dataset.pollAnonymous = '1';
el.innerHTML = `
${replyHtml}
<div class="message-row">
${avatarHtml}
<div class="message-body">
${replyHtml}
<div class="message-header">
<span class="message-author" style="color:${color}"${this._nicknames[msg.user_id] ? ` title="${this._escapeHtml(msg.username)}"` : ''}>${this._escapeHtml(this._getNickname(msg.user_id, msg.username))}</span>
${msgRoleIconBefore}
<span class="message-author" style="color:${authorColor}"${this._nicknames[msg.user_id] ? ` title="${this._escapeHtml(msg.username)}"` : ''}>${this._escapeHtml(this._getNickname(msg.user_id, msg.username))}</span>
${msgRoleIconAfter}
${botBadge}
${msgRoleBadge}
<span class="message-time">${this._formatTime(msg.created_at)}</span>
@ -569,6 +720,7 @@ _createMessageEl(msg, prevMsg) {
<div class="message-content">${this._formatContent(msg.content)}${editedHtml}</div>
${pollHtml}
${reactionsHtml}
${threadHtml}
</div>
${toolbarHtml}
<button class="msg-dots-btn" aria-label="${t('app.actions.message_actions')}"></button>
@ -612,6 +764,15 @@ _promoteCompactToFull(compactEl) {
? `<span class="user-role-badge msg-role-badge" style="color:${this._safeColor(onlineUser.role.color, 'var(--text-muted)')}">${this._escapeHtml(onlineUser.role.name)}</span>`
: '';
// Role icon in chat (compact-to-full)
const showIconChat2 = this.serverSettings.role_icon_chat === 'true';
const iconAfterName2 = this.serverSettings.role_icon_after_name === 'true';
const msgRoleIcon2 = showIconChat2 && onlineUser && onlineUser.role && onlineUser.role.icon
? `<img class="role-icon" src="${this._escapeHtml(onlineUser.role.icon)}" alt="" title="${this._escapeHtml(onlineUser.role.name)}">`
: '';
const msgRoleIconBefore2 = msgRoleIcon2 && !iconAfterName2 ? msgRoleIcon2 : '';
const msgRoleIconAfter2 = msgRoleIcon2 && iconAfterName2 ? msgRoleIcon2 : '';
// Replace the compact element in-place
const wasAnnouncement = compactEl.classList.contains('announcement');
compactEl.className = 'message' + (isPinned ? ' pinned' : '') + (wasAnnouncement ? ' announcement' : '');
@ -624,7 +785,9 @@ _promoteCompactToFull(compactEl) {
${avatarHtml}
<div class="message-body">
<div class="message-header">
${msgRoleIconBefore2}
<span class="message-author" style="color:${color}"${this._nicknames[userId] ? ` title="${this._escapeHtml(username)}"` : ''}>${this._escapeHtml(this._getNickname(userId, username))}</span>
${msgRoleIconAfter2}
${msgRoleBadge}
<span class="message-time">${this._formatTime(time)}</span>
${pinnedTag}
@ -650,6 +813,16 @@ _appendSystemMessage(text) {
if (wasAtBottom) this._scrollToBottom(true);
},
_appendWelcomeMessage(text) {
const container = document.getElementById('messages');
const wasAtBottom = this._coupledToBottom;
const el = document.createElement('div');
el.className = 'welcome-message';
el.textContent = text;
container.appendChild(el);
if (wasAtBottom) this._scrollToBottom(true);
},
// ── Pinned Messages Panel ─────────────────────────────
_renderPinnedPanel(pins) {
@ -675,17 +848,13 @@ _renderPinnedPanel(pins) {
}
panel.style.display = 'block';
// Click to scroll to pinned message
// Click to scroll to pinned message (uses _jumpToMessage to handle
// messages that have been trimmed from the DOM)
list.querySelectorAll('.pinned-item').forEach(item => {
item.addEventListener('click', () => {
const msgId = item.dataset.msgId;
const msgEl = document.querySelector(`#messages [data-msg-id="${msgId}"]`);
if (msgEl) {
msgEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
msgEl.classList.add('highlight-flash');
setTimeout(() => msgEl.classList.remove('highlight-flash'), 2000);
}
const msgId = parseInt(item.dataset.msgId, 10);
panel.style.display = 'none';
if (msgId) this._jumpToMessage(msgId);
});
});
},
@ -957,6 +1126,7 @@ _enterMoveSelectionMode() {
_exitMoveSelectionMode() {
this._moveSelectionActive = false;
this._moveSelectedIds.clear();
this._lastMoveSelectedEl = null;
document.body.classList.remove('move-selection-mode');
const toolbar = document.getElementById('move-msg-toolbar');
if (toolbar) toolbar.style.display = 'none';

View file

@ -92,6 +92,9 @@ _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) {
@ -161,9 +164,16 @@ _initDesktopAppBanner() {
});
}
// Close on overlay click
// 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';
}
});
@ -259,14 +269,26 @@ _initAndroidBetaBanner() {
});
}
// Close on overlay click
// Close on overlay click — respect "don't show again" checkbox
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.style.display = 'none';
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',
@ -300,6 +322,8 @@ async _setupDesktopShortcuts() {
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');
@ -307,6 +331,10 @@ async _setupDesktopShortcuts() {
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();
@ -328,8 +356,11 @@ async _setupDesktopShortcuts() {
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');
}
@ -341,6 +372,7 @@ async _setupDesktopShortcuts() {
clearBtn.addEventListener('click', async () => {
try {
await window.havenDesktop.shortcuts.setConfig({ [action]: '' });
config[action] = '';
keyEl.textContent = '—';
} catch (err) {}
});
@ -424,9 +456,17 @@ async _initE2E() {
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();
@ -445,6 +485,49 @@ async _initE2E() {
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). */

View file

@ -154,23 +154,35 @@ _setupSocketListeners() {
}, 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);
// Defer to requestAnimationFrame so the browser recalculates layout
// before we read scrollHeight — avoids jumping to wrong position.
if (this._coupledToBottom) {
this._suppressCoupleCheck = true;
requestAnimationFrame(() => {
this._scrollToBottom(true);
this._suppressCoupleCheck = false;
});
}
// Skip heavy refresh if we just handled a 'connect' event (avoids doubled emits)
const sinceLast = Date.now() - (this._lastConnectTime || 0);
if (sinceLast < 3000) return;
// Re-fetch current channel messages + member list to catch anything missed
// Only do a full reset if coupled to bottom — if the user was browsing
// history before the tab switch, preserve their position by skipping the
// reset so _renderMessages doesn't yank them to the latest messages.
if (this.currentChannel && this.socket?.connected) {
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 });
if (this._coupledToBottom) {
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 });
}
this.socket.emit('get-channel-members', { code: this.currentChannel });
}
// Re-fetch channels in case list changed while backgrounded
@ -220,6 +232,7 @@ _setupSocketListeners() {
if (err.message === 'Invalid token' || err.message === 'Authentication required' || err.message === 'Session expired') {
localStorage.removeItem('haven_token');
localStorage.removeItem('haven_user');
localStorage.removeItem('haven_sync_key');
window.location.href = '/';
}
this._setLed('connection-led', 'danger');
@ -277,6 +290,64 @@ _setupSocketListeners() {
// (covers cases where initial push arrived before DOM was ready)
this.socket.emit('get-voice-counts');
// Auto-join via invite link (vanity code or channel code in query param)
const urlParams = new URLSearchParams(window.location.search);
const inviteCode = urlParams.get('invite');
if (inviteCode && !this._inviteHandled) {
this._inviteHandled = true;
this.socket.emit('join-channel', { code: inviteCode });
sessionStorage.removeItem('haven_pending_invite');
// Clean up the URL
const cleanUrl = window.location.pathname;
window.history.replaceState({}, '', cleanUrl);
}
// Channel / message deep link (?channel=CODE[&message=ID])
const linkChannel = urlParams.get('channel');
const linkMessage = urlParams.get('message');
if (linkChannel && !this._channelLinkHandled) {
this._channelLinkHandled = true;
sessionStorage.removeItem('haven_pending_channel');
sessionStorage.removeItem('haven_pending_message');
const known = (channels || []).some(c => c.code === linkChannel);
const go = () => {
this.switchChannel(linkChannel);
if (linkMessage) {
const msgId = parseInt(linkMessage, 10);
if (!isNaN(msgId)) {
// Wait briefly for messages to load before jumping
setTimeout(() => this._jumpToMessage(msgId), 600);
}
}
};
if (known) {
go();
} else {
// Try to join the channel by code first; if that succeeds the channel
// list will update and we can switch. If it fails, fall through silently.
this.socket.emit('join-channel', { code: linkChannel }, (res) => {
if (res && res.error) {
this._showToast?.(t('toasts.channel_link_unavailable') || 'Channel not available on this server', 'error');
} else {
setTimeout(go, 200);
}
});
}
window.history.replaceState({}, '', window.location.pathname);
}
// Re-evaluate input area visibility for the current channel (read-only, text/media toggles may have changed)
if (this.currentChannel) {
const curCh = this.channels.find(c => c.code === this.currentChannel);
if (curCh) {
const msgInputArea = document.getElementById('message-input-area');
const _textOff = curCh.text_enabled === 0;
const _mediaOff = curCh.media_enabled === 0;
const _isReadOnly = curCh.read_only === 1 && !this.user?.isAdmin && !this._hasPerm('read_only_override');
if (msgInputArea) msgInputArea.style.display = (_isReadOnly || (_textOff && _mediaOff)) ? 'none' : '';
}
}
// If the channel code rotated while we were disconnected, re-enter with the
// new code so messages, reactions, and presence start working again.
if (rotatedChannelId !== null) {
@ -357,6 +428,20 @@ _setupSocketListeners() {
this._newestMsgId = data.messages[data.messages.length - 1].id;
this._appendMessages(data.messages);
this._loadingFuture = false;
} else if (data.around) {
// Jump-to-message — replace everything and scroll to target
if (data.messages.length > 0) {
this._oldestMsgId = data.messages[0].id;
this._newestMsgId = data.messages[data.messages.length - 1].id;
}
this._noMoreHistory = false;
this._noMoreFuture = false;
this._loadingHistory = false;
this._loadingFuture = false;
this._historyBefore = null;
this._historyAfter = null;
// _jumpTargetId is already set by _jumpToMessage — _renderMessages reads it
this._renderMessages(data.messages);
} else {
// Initial load — replace everything
this._noMoreFuture = true;
@ -367,7 +452,7 @@ _setupSocketListeners() {
} else {
this._noMoreHistory = true;
}
this._renderMessages(data.messages);
this._renderMessages(data.messages, data.lastReadMessageId);
}
// Re-append any pending E2E notice (survives message re-render after key change)
@ -384,6 +469,7 @@ _setupSocketListeners() {
// Simple rule: near bottom → true, scrolled up at all → false.
this._coupledToBottom = true;
let lastScrollTop = msgContainer.scrollTop;
const jumpBtn = document.getElementById('jump-to-bottom');
msgContainer.addEventListener('scroll', () => {
if (this._suppressCoupleCheck) return;
const st = msgContainer.scrollTop;
@ -398,8 +484,22 @@ _setupSocketListeners() {
this._coupledToBottom = false;
}
lastScrollTop = st;
// Show/hide jump-to-bottom button
if (jumpBtn) {
if (dist > 400) jumpBtn.classList.add('visible');
else jumpBtn.classList.remove('visible');
}
}, { passive: true });
// Jump-to-bottom click handler
if (jumpBtn) {
jumpBtn.addEventListener('click', () => {
this._scrollToBottom(true);
this._coupledToBottom = true;
jumpBtn.classList.remove('visible');
});
}
this._historyDebounce = 0; // timestamp of last history request
msgContainer.addEventListener('scroll', () => {
if (this._suppressCoupleCheck) return;
@ -480,14 +580,22 @@ _setupSocketListeners() {
const mentionRegex = new RegExp(`@${this.user.username}\\b`, 'i');
const _notifCh = this.channels.find(c => c.code === data.channelCode);
const _isAnnouncement = _notifCh && _notifCh.notification_type === 'announcement';
if (mentionRegex.test(data.message.content)) {
this.notifications.play('mention');
const _isReplyToMe = data.message.replyContext && data.message.replyContext.user_id === this.user.id;
const _isDm = _notifCh && _notifCh.is_dm;
const _isMention = mentionRegex.test(data.message.content);
const _notifOpts = _isMention ? { isMention: true } : _isReplyToMe ? { isReply: true } : _isDm ? { isDm: true } : null;
if (_isMention) {
this.notifications.play('mention', { isMention: true });
} else if (_isReplyToMe) {
this.notifications.play('reply', { isReply: true });
} else if (_isDm) {
this.notifications.play('message', { isDm: true });
} else {
this.notifications.play(_isAnnouncement ? 'announcement' : 'message');
}
// Fire native OS notification if tab is hidden (alt-tabbed, minimised, etc.)
if (document.hidden) {
this._fireNativeNotification(data.message, data.channelCode);
this._fireNativeNotification(data.message, data.channelCode, _notifOpts);
}
}
}
@ -510,13 +618,21 @@ _setupSocketListeners() {
const mentionRegex = new RegExp(`@${this.user.username}\\b`, 'i');
const _notifCh2 = this.channels.find(c => c.code === data.channelCode);
const _isAnnouncement2 = _notifCh2 && _notifCh2.notification_type === 'announcement';
if (mentionRegex.test(data.message.content)) {
this.notifications.play('mention');
const _isReplyToMe2 = data.message.replyContext && data.message.replyContext.user_id === this.user.id;
const _isDm2 = _notifCh2 && _notifCh2.is_dm;
const _isMention2 = mentionRegex.test(data.message.content);
const _notifOpts2 = _isMention2 ? { isMention: true } : _isReplyToMe2 ? { isReply: true } : _isDm2 ? { isDm: true } : null;
if (_isMention2) {
this.notifications.play('mention', { isMention: true });
} else if (_isReplyToMe2) {
this.notifications.play('reply', { isReply: true });
} else if (_isDm2) {
this.notifications.play('message', { isDm: true });
} else {
this.notifications.play(_isAnnouncement2 ? 'announcement' : 'message');
}
// Fire native OS notification when tab/window is not visible
this._fireNativeNotification(data.message, data.channelCode);
this._fireNativeNotification(data.message, data.channelCode, _notifOpts2);
}
}
@ -586,6 +702,12 @@ _setupSocketListeners() {
if (data.channelCode === this.currentChannel) {
this._appendSystemMessage(t('header.messages.user_joined', { name: this._getNickname(data.user.id, data.user.username) }));
this.notifications.play('join');
// Show configurable welcome message if set
const welcomeTemplate = this.serverSettings?.welcome_message;
if (welcomeTemplate) {
const welcomeText = welcomeTemplate.replace(/\{user\}/gi, this._getNickname(data.user.id, data.user.username));
this._appendWelcomeMessage(welcomeText);
}
}
});
@ -641,6 +763,45 @@ _setupSocketListeners() {
}
});
// ── Threads ───────────────────────────────────────
this.socket.on('thread-messages', (data) => {
if (data.parentUsername) {
this._setThreadParentHeader({
username: data.parentUsername,
avatar: data.parentAvatar || null,
avatarShape: data.parentAvatarShape || 'circle'
});
}
// Update parent preview from server (authoritative source)
if (data.parentContent) {
const preview = document.getElementById('thread-parent-preview');
if (preview) {
const text = data.parentContent.length > 120 ? data.parentContent.substring(0, 120) + '…' : data.parentContent;
preview.textContent = text;
}
}
const container = document.getElementById('thread-messages');
if (!container) return;
container.innerHTML = '';
if (data.messages) {
data.messages.forEach(msg => this._appendThreadMessage(msg));
}
});
this.socket.on('new-thread-message', (data) => {
if (data.channelCode !== this.currentChannel) return;
// If this thread is open, append the message
if (this._activeThreadParent === data.parentId) {
this._appendThreadMessage(data.message);
}
});
this.socket.on('thread-updated', (data) => {
if (data.channelCode !== this.currentChannel) return;
this._updateThreadPreview(data.parentId, data.thread);
});
// ── Polls ─────────────────────────────────────────
this.socket.on('poll-updated', (data) => {
if (data.channelCode === this.currentChannel) {
@ -847,12 +1008,33 @@ _setupSocketListeners() {
}
});
// Update DM sidebar names when a user renames
this.socket.on('dm-name-updated', (data) => {
if (!data || !data.userId || !data.newName) return;
let needsRender = false;
for (const ch of this.channels) {
if (ch.is_dm && ch.dm_target && ch.dm_target.id === data.userId) {
ch.dm_target.username = data.newName;
needsRender = true;
}
}
if (needsRender) {
this._renderChannels(this.channels);
// Update channel header if currently viewing a DM with this user
const curCh = this.channels.find(c => c.code === this.currentChannel);
if (curCh && curCh.is_dm && curCh.dm_target && curCh.dm_target.id === data.userId) {
const headerName = document.querySelector('.channel-info h3');
if (headerName) headerName.textContent = `@ ${this._getNickname(data.userId, data.newName)}`;
}
}
});
// ── Message edit / delete ──────────────────────────
this.socket.on('message-edited', async (data) => {
if (data.channelCode === this.currentChannel) {
const msgEl = document.querySelector(`[data-msg-id="${data.messageId}"]`);
if (!msgEl) return;
const contentEl = msgEl.querySelector('.message-content');
const contentEl = msgEl.querySelector('.message-content, .thread-msg-content');
if (contentEl) {
// E2E: decrypt if needed
let displayContent = data.content;
@ -900,6 +1082,13 @@ _setupSocketListeners() {
}
});
// ── Bot soundboard trigger ───────────────────────
this.socket.on('play-sound', (data) => {
if (data.channelCode === this.currentChannel && data.soundUrl) {
this._playSoundFile(data.soundUrl);
}
});
// ── Messages moved (source channel) ──────────────
this.socket.on('messages-moved', (data) => {
if (data.channelCode === this.currentChannel) {
@ -971,8 +1160,12 @@ _setupSocketListeners() {
}
});
this.socket.on('pinned-messages', (data) => {
this.socket.on('pinned-messages', async (data) => {
if (data.channelCode === this.currentChannel) {
// Decrypt E2E-encrypted pinned messages in DMs before rendering
if (data.pins && data.pins.length) {
await this._decryptMessages(data.pins, data.channelCode);
}
this._renderPinnedPanel(data.pins);
}
});
@ -1103,14 +1296,35 @@ _setupSocketListeners() {
const panel = document.getElementById('search-results-panel');
const list = document.getElementById('search-results-list');
const count = document.getElementById('search-results-count');
count.textContent = t(data.results.length === 1 ? 'header.search_results_one' : 'header.search_results_other', { count: data.results.length, query: this._escapeHtml(data.query) });
if (data.isDM) {
count.textContent = t('header.search_results_other', { count: 0, query: this._escapeHtml(data.query) });
list.innerHTML = `<p class="muted-text" style="padding:12px">Search is not available in DMs because messages are end-to-end encrypted.</p>`;
panel.style.display = 'block';
return;
}
// Build header with active filters
let filterInfo = '';
if (data.filters) {
const tags = [];
if (data.filters.from) tags.push(`<span class="search-filter-tag">from:${this._escapeHtml(data.filters.from)}</span>`);
if (data.filters.in) tags.push(`<span class="search-filter-tag">in:#${this._escapeHtml(data.filters.in)}</span>`);
if (data.filters.has) tags.push(`<span class="search-filter-tag">has:${this._escapeHtml(data.filters.has)}</span>`);
if (tags.length) filterInfo = `<div class="search-filter-tags">${tags.join(' ')}</div>`;
}
count.innerHTML = t(data.results.length === 1 ? 'header.search_results_one' : 'header.search_results_other', { count: data.results.length, query: this._escapeHtml(data.query) }) + filterInfo;
// Strip filters from query for highlight
const highlightQuery = data.query.replace(/\b(?:from|in|has):\S+/gi, '').trim();
list.innerHTML = data.results.length === 0
? `<p class="muted-text" style="padding:12px">${t('header.search_no_results')}</p>`
: data.results.map(r => `
<div class="search-result-item" data-msg-id="${r.id}">
<span class="search-result-author" style="color:${this._getUserColor(r.username)}">${this._escapeHtml(this._getNickname(r.user_id, r.username))}</span>
<span class="search-result-time">${this._formatTime(r.created_at)}</span>
<div class="search-result-content">${this._highlightSearch(this._escapeHtml(r.content), data.query)}</div>
<div class="search-result-content">${highlightQuery ? this._highlightSearch(this._escapeHtml(r.content), highlightQuery) : this._escapeHtml(r.content)}</div>
</div>
`).join('');
panel.style.display = 'block';
@ -1118,13 +1332,12 @@ _setupSocketListeners() {
// Click to scroll to message
list.querySelectorAll('.search-result-item').forEach(item => {
item.addEventListener('click', () => {
const msgId = item.dataset.msgId;
const msgEl = document.querySelector(`[data-msg-id="${msgId}"]`);
if (msgEl) {
msgEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
msgEl.classList.add('highlight-flash');
setTimeout(() => msgEl.classList.remove('highlight-flash'), 2000);
}
const msgId = parseInt(item.dataset.msgId, 10);
// Close the search panel so the user can see the result
panel.style.display = 'none';
document.getElementById('search-container').style.display = 'none';
document.getElementById('search-input').value = '';
this._jumpToMessage(msgId);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -173,11 +173,25 @@ _renderUserItem(u, scoreLookup) {
const avatarHtml = `<div class="user-avatar-wrapper">${avatarImg}<span class="user-status-dot${statusClass ? ' ' + statusClass : ''}"></span></div>`;
// Role: color dot to the left of name + tooltip on hover
// Role display mode
const roleDisplayMode = localStorage.getItem('haven-role-display') || 'colored-name';
const roleColor = u.role ? this._safeColor(u.role.color, 'var(--text-muted)') : '';
const roleDot = u.role
const showIconSidebar = (this.serverSettings.role_icon_sidebar || 'true') === 'true';
const iconAfterName = this.serverSettings.role_icon_after_name === 'true';
const roleIconHtml = showIconSidebar && u.role && u.role.icon
? `<img class="role-icon" src="${this._escapeHtml(u.role.icon)}" alt="" title="${this._escapeHtml(u.role.name)}">`
: '';
const roleIconBefore = roleIconHtml && !iconAfterName ? roleIconHtml : '';
const roleIconAfter = roleIconHtml && iconAfterName ? roleIconHtml : '';
const roleDot = (roleDisplayMode === 'dot' && u.role)
? `<span class="user-role-dot" style="background:${roleColor}" title="${this._escapeHtml(u.role.name)}"></span>`
: '';
// In colored-name mode, apply role color to the username
const nameStyle = (roleDisplayMode === 'colored-name' && u.role && roleColor)
? ` style="color:${roleColor}"`
: '';
// Keep the old badge for message area (msg-role-badge) but hide in sidebar
const roleBadge = u.role
? `<span class="user-role-badge" style="color:${this._safeColor(u.role.color, 'var(--text-muted)')}" title="${this._escapeHtml(u.role.name)}">${this._escapeHtml(u.role.name)}</span>`
@ -206,8 +220,9 @@ _renderUserItem(u, scoreLookup) {
return `
<div class="user-item${onlineClass}" data-user-id="${u.id}">
${avatarHtml}
${roleDot}
<span class="user-item-name"${this._nicknames[u.id] ? ` title="${this._escapeHtml(u.username)}"` : ''}>${this._escapeHtml(this._getNickname(u.id, u.username))}</span>
${roleDot}${roleIconBefore}
<span class="user-item-name"${nameStyle}${this._nicknames[u.id] ? ` title="${this._escapeHtml(u.username)}"` : ''}>${this._escapeHtml(this._getNickname(u.id, u.username))}</span>
${roleIconAfter}
${roleBadge}
${statusTextHtml}
${scoreBadge}
@ -243,9 +258,10 @@ _showProfilePopup(profile) {
// Roles
const rolesHtml = (profile.roles && profile.roles.length > 0)
? profile.roles.map(r =>
`<span class="profile-popup-role" style="border-color:${this._safeColor(r.color, 'var(--border-light)')}; color:${this._safeColor(r.color, 'var(--text-secondary)')}"><span class="profile-role-dot" style="background:${this._safeColor(r.color, 'var(--text-muted)')}"></span>${this._escapeHtml(r.name)}</span>`
).join('')
? profile.roles.map(r => {
const rIcon = r.icon ? `<img class="role-icon" src="${this._escapeHtml(r.icon)}" alt="">` : `<span class="profile-role-dot" style="background:${this._safeColor(r.color, 'var(--text-muted)')}"></span>`;
return `<span class="profile-popup-role" style="border-color:${this._safeColor(r.color, 'var(--border-light)')}; color:${this._safeColor(r.color, 'var(--text-secondary)')}">${rIcon}${this._escapeHtml(r.name)}</span>`;
}).join('')
: '';
// Status text badge

View file

@ -148,7 +148,7 @@ _formatContent(str) {
try { new URL(url); } catch { return full; }
const safeUrl = url.replace(/['"<>]/g, '');
const idx = mdLinks.length;
mdLinks.push(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow">${text}</a>`);
mdLinks.push(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow" title="${safeUrl}" data-masked-link="true">${text}</a>`);
return `\x00MDLINK_${idx}\x00`;
});
@ -203,12 +203,24 @@ _formatContent(str) {
// Render ~~strikethrough~~
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Render ==highlight==
html = html.replace(/==(.+?)==/g, '<mark class="chat-highlight">$1</mark>');
// Render `inline code`
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
// Render > blockquotes (lines starting with >)
html = html.replace(/(?:^|\n)&gt;\s?(.+)/g, (_, text) => {
return `\n<blockquote class="chat-blockquote">${text}</blockquote>`;
// Render grouped > blockquotes and preserve attribution lines inside the quote.
const blockquotes = [];
html = html.replace(/(^|\n)((?:&gt;[^\n]*(?:\n|$))+)/g, (full, pre, block) => {
const lines = block.trim().split('\n').map(line => line.replace(/^&gt;\s?/, ''));
let authorHtml = '';
if (lines[0] && /^@[^\s].+ wrote:$/.test(lines[0])) {
authorHtml = `<div class="chat-blockquote-author">${lines.shift()}</div>`;
}
const textHtml = lines.join('<br>');
const idx = blockquotes.length;
blockquotes.push(`${pre}<blockquote class="chat-blockquote">${authorHtml}<div class="chat-blockquote-body">${textHtml}</div></blockquote>`);
return `\x00BLOCKQUOTE_${idx}\x00`;
});
// ── Headings: # H1, ## H2, ### H3 at start of line ──
@ -230,14 +242,20 @@ _formatContent(str) {
// ── Ordered lists: consecutive lines starting with "N. " ──
html = html.replace(/((?:(?:^|\n)\d+\.\s+.+)+)/g, (match) => {
const items = match.trim().split('\n').map(line =>
const lines = match.trim().split('\n');
const startNum = lines[0].match(/^(\d+)/)?.[1] || '1';
const items = lines.map(line =>
`<li>${line.replace(/^\d+\.\s+/, '')}</li>`
).join('');
return `\n<ol class="chat-list">${items}</ol>`;
return `\n<ol class="chat-list" start="${startNum}">${items}</ol>`;
});
html = html.replace(/\n/g, '<br>');
blockquotes.forEach((block, idx) => {
html = html.replace(`\x00BLOCKQUOTE_${idx}\x00`, block);
});
// ── Restore fenced code blocks ──
codeBlocks.forEach((block, idx) => {
const escaped = this._escapeHtml(block.code).replace(/\n$/, '');
@ -381,6 +399,36 @@ _showRecoveryNotice() {
},
/** Warn users before downloading potentially harmful file types */
_showExternalLinkWarning(displayText, url) {
document.querySelector('.risky-download-overlay')?.remove();
const overlay = document.createElement('div');
overlay.className = 'risky-download-overlay';
overlay.innerHTML = `
<div class="risky-download-modal">
<div class="risky-download-icon">🔗</div>
<h3 style="color:var(--text-primary,#dbdee1)">External Link</h3>
<p>You're about to visit:</p>
<p style="background:var(--bg-tertiary,#232428);padding:8px 12px;border-radius:6px;font-size:13px;word-break:break-all;color:var(--accent,#5865f2)">${this._escapeHtml(url)}</p>
<p class="risky-download-desc">Make sure you trust this link before continuing.</p>
<div class="risky-download-actions">
<button class="risky-download-cancel">Cancel</button>
<button class="risky-download-confirm" style="background:var(--accent,#5865f2)">Open Link</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('.risky-download-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector('.risky-download-confirm').addEventListener('click', () => {
overlay.remove();
window.open(url, '_blank', 'noopener,noreferrer');
});
},
_showRiskyDownloadWarning(fileName, ext, url) {
// Remove any existing warning overlay
document.querySelector('.risky-download-overlay')?.remove();
@ -524,7 +572,8 @@ _toggleEmojiPicker() {
btn.textContent = emoji;
}
btn.addEventListener('click', () => {
const input = document.getElementById('message-input');
// Insert into the active edit textarea if editing, otherwise the main input
const input = self._activeEditTextarea || document.getElementById('message-input');
const start = input.selectionStart;
const end = input.selectionEnd;
input.value = input.value.substring(0, start) + emoji + input.value.substring(end);
@ -541,6 +590,19 @@ _toggleEmojiPicker() {
});
renderGrid();
// On mobile with the iOS keyboard open, dynamically position the picker
// above the input area using the visual viewport so it doesn't push
// content off-screen.
if (window.innerWidth <= 480 && window.visualViewport) {
const vvHeight = window.visualViewport.height;
const inputArea = document.getElementById('message-input-area');
if (inputArea) {
const inputRect = inputArea.getBoundingClientRect();
picker.style.bottom = (window.innerHeight - inputRect.top) + 'px';
}
}
picker.style.display = 'flex';
searchInput.focus();
},
@ -870,6 +932,7 @@ _renderReactions(msgId, reactions) {
const badges = Object.values(grouped).map(g => {
const isOwn = g.users.some(u => u.id === this.user.id);
const names = g.users.map(u => u.username).join(', ');
const usersJson = this._escapeHtml(JSON.stringify(g.users.map(u => u.username)));
// Check if it's a custom emoji
const customMatch = g.emoji.match(/^:([a-zA-Z0-9_-]+):$/);
let emojiDisplay = g.emoji;
@ -877,7 +940,7 @@ _renderReactions(msgId, reactions) {
const ce = this.customEmojis.find(e => e.name === customMatch[1]);
if (ce) emojiDisplay = `<img src="${this._escapeHtml(ce.url)}" alt=":${this._escapeHtml(ce.name)}:" class="custom-emoji reaction-custom-emoji">`;
}
return `<button class="reaction-badge${isOwn ? ' own' : ''}" data-emoji="${this._escapeHtml(g.emoji)}" title="${names}">${emojiDisplay} ${g.users.length}</button>`;
return `<button class="reaction-badge${isOwn ? ' own' : ''}" data-emoji="${this._escapeHtml(g.emoji)}" data-users="${usersJson}" title="${names}">${emojiDisplay} ${g.users.length}</button>`;
}).join('');
return `<div class="reactions-row">${badges}</div>`;
@ -897,8 +960,8 @@ _updateMessageReactions(messageId, reactions) {
const html = this._renderReactions(messageId, reactions);
if (!html) { if (wasAtBottom) this._scrollToBottom(true); return; }
// Find where to insert — after .message-content
const content = msgEl.querySelector('.message-content');
// Find where to insert — after main or thread message content
const content = msgEl.querySelector('.message-content, .thread-msg-content');
if (content) {
content.insertAdjacentHTML('afterend', html);
}
@ -906,6 +969,49 @@ _updateMessageReactions(messageId, reactions) {
if (wasAtBottom) this._scrollToBottom(true);
},
// ── Reaction popout (who reacted) ─────────────────────
_showReactionPopout(badge) {
this._hideReactionPopout();
let users;
try { users = JSON.parse(badge.dataset.users || '[]'); } catch { return; }
if (!users.length) return;
const emoji = badge.dataset.emoji;
const customMatch = emoji.match(/^:([a-zA-Z0-9_-]+):$/);
let emojiDisplay = emoji;
if (customMatch && this.customEmojis) {
const ce = this.customEmojis.find(e => e.name === customMatch[1]);
if (ce) emojiDisplay = `<img src="${this._escapeHtml(ce.url)}" alt=":${this._escapeHtml(ce.name)}:" class="custom-emoji reaction-custom-emoji">`;
}
const popout = document.createElement('div');
popout.id = 'reaction-popout';
popout.className = 'reaction-popout';
popout.innerHTML = `
<div class="reaction-popout-header">${emojiDisplay} <span class="reaction-popout-count">${users.length}</span></div>
<div class="reaction-popout-list">
${users.map(u => `<div class="reaction-popout-user">${this._escapeHtml(u)}</div>`).join('')}
</div>
`;
document.body.appendChild(popout);
// Position above the badge
const rect = badge.getBoundingClientRect();
popout.style.left = rect.left + 'px';
popout.style.top = (rect.top - popout.offsetHeight - 6) + 'px';
// Clamp to viewport
const pr = popout.getBoundingClientRect();
if (pr.right > window.innerWidth) popout.style.left = (window.innerWidth - pr.width - 8) + 'px';
if (pr.left < 0) popout.style.left = '8px';
if (pr.top < 0) popout.style.top = (rect.bottom + 6) + 'px';
},
_hideReactionPopout() {
const existing = document.getElementById('reaction-popout');
if (existing) existing.remove();
},
_getQuickEmojis() {
const saved = localStorage.getItem('haven_quick_emojis');
if (saved) {
@ -1130,17 +1236,10 @@ _showReactionPicker(msgEl, msgId) {
// Flip picker below the message if it would be clipped above
requestAnimationFrame(() => {
const pickerRect = picker.getBoundingClientRect();
if (pickerRect.top < 0) {
const container = msgEl.closest('#thread-messages, #messages');
const containerTop = container ? container.getBoundingClientRect().top : 0;
if (pickerRect.top < containerTop + 4) {
picker.classList.add('flip-below');
} else {
// Also check against the messages container top (channel header/topic)
const container = document.getElementById('messages');
if (container) {
const containerRect = container.getBoundingClientRect();
if (pickerRect.top < containerRect.top) {
picker.classList.add('flip-below');
}
}
}
});
@ -1262,11 +1361,308 @@ _showFullReactionPicker(msgEl, msgId, quickPicker) {
searchTimer = setTimeout(() => renderAll(searchInput.value.trim()), 150);
});
// Position the panel near the quick picker
msgEl.appendChild(panel);
// Position the panel relative to quick picker so it never overlaps it
quickPicker.appendChild(panel);
if (quickPicker.classList.contains('flip-below')) {
panel.classList.add('flip-below');
}
requestAnimationFrame(() => {
const panelRect = panel.getBoundingClientRect();
const container = msgEl.closest('#thread-messages, #messages');
const containerTop = container ? container.getBoundingClientRect().top : 0;
if (panelRect.top < containerTop + 4) {
panel.classList.add('flip-below');
}
});
searchInput.focus();
},
// ═══════════════════════════════════════════════════════
// THREADS
// ═══════════════════════════════════════════════════════
_renderThreadPreview(parentId, thread) {
if (!thread || !thread.count) return '';
const participantAvatars = (thread.participants || []).map(p => {
if (p.avatar) {
return `<img class="thread-participant-avatar" src="${this._escapeHtml(p.avatar)}" alt="${this._escapeHtml(p.username)}" title="${this._escapeHtml(p.username)}">`;
}
const color = this._getUserColor(p.username);
const initial = p.username.charAt(0).toUpperCase();
return `<div class="thread-participant-avatar thread-participant-initial" style="background:${color}" title="${this._escapeHtml(p.username)}">${initial}</div>`;
}).join('');
const timeAgo = this._relativeTime(thread.lastReplyAt);
return `
<button class="thread-preview" data-thread-parent="${parentId}">
${participantAvatars}
<span class="thread-preview-count">${thread.count} ${thread.count === 1 ? 'Reply' : 'Replies'}</span>
<span class="thread-preview-time">${timeAgo}</span>
<span class="thread-preview-arrow"></span>
</button>
`;
},
_relativeTime(isoStr) {
if (!isoStr) return '';
const diff = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
},
_setThreadParentHeader(meta = {}) {
const wrap = document.getElementById('thread-parent-avatar-wrap');
const nameEl = document.getElementById('thread-parent-name');
if (!wrap || !nameEl) return;
const username = (meta.username || '').trim() || 'Thread starter';
const shape = (meta.avatarShape || 'circle') === 'square' ? 'square' : 'circle';
const shapeClass = shape === 'square' ? ' thread-parent-avatar-square' : '';
if (meta.avatar) {
wrap.innerHTML = `<img class="thread-parent-avatar${shapeClass}" src="${this._escapeHtml(meta.avatar)}" alt="${this._escapeHtml(username)}">`;
} else {
const initial = username.charAt(0).toUpperCase() || '?';
const color = this._getUserColor(username);
wrap.innerHTML = `<div class="thread-parent-avatar-initial${shapeClass}" style="background:${color}">${this._escapeHtml(initial)}</div>`;
}
nameEl.textContent = username;
nameEl.title = username;
},
_setThreadReply(msgEl, msgId) {
const author = msgEl.querySelector('.thread-msg-author')?.textContent || 'someone';
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.thread-msg-content')?.textContent || '';
const preview = rawContent.length > 70 ? rawContent.substring(0, 70) + '…' : rawContent;
this._threadReplyingTo = { id: msgId, username: author, content: rawContent };
const bar = document.getElementById('thread-reply-bar');
const text = document.getElementById('thread-reply-preview-text');
if (!bar || !text) return;
bar.style.display = 'flex';
text.innerHTML = `Replying to <strong>${this._escapeHtml(author)}</strong>: ${this._escapeHtml(preview)}`;
const input = document.getElementById('thread-input');
if (input) input.focus();
},
_clearThreadReply() {
this._threadReplyingTo = null;
const bar = document.getElementById('thread-reply-bar');
if (bar) bar.style.display = 'none';
},
_quoteThreadMessage(msgEl) {
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.thread-msg-content')?.textContent || '';
const author = msgEl.querySelector('.thread-msg-author')?.textContent || 'someone';
const quotedLines = rawContent.split('\n').map(l => `> ${l}`).join('\n');
const quoteText = `> @${author} wrote:\n${quotedLines}\n`;
const input = document.getElementById('thread-input');
if (!input) return;
if (input.value) {
input.value += '\n' + quoteText;
} else {
input.value = quoteText;
}
input.focus();
input.dispatchEvent(new Event('input'));
},
_openThread(parentId) {
this._activeThreadParent = parentId;
const panel = document.getElementById('thread-panel');
if (!panel) return;
panel.style.display = 'flex';
panel.dataset.parentId = parentId;
this._setThreadPiPEnabled(localStorage.getItem('haven_thread_panel_pip') === '1');
// Request thread messages from server
this.socket.emit('get-thread-messages', { parentId });
// Update header
const msgEl = document.querySelector(`[data-msg-id="${parentId}"]`);
const author = msgEl?.querySelector('.message-author')?.textContent || 'Thread starter';
document.getElementById('thread-panel-title').textContent = 'Thread';
const parentPreview = msgEl?.querySelector('.message-content')?.textContent || '';
document.getElementById('thread-parent-preview').textContent = parentPreview.length > 120 ? parentPreview.substring(0, 120) + '…' : parentPreview;
const avatarImg = msgEl?.querySelector('.message-avatar-img');
let avatar = null;
if (avatarImg && avatarImg.getAttribute('src')) avatar = avatarImg.getAttribute('src');
const avatarShape = (avatarImg && avatarImg.classList.contains('avatar-square')) ? 'square' : 'circle';
this._setThreadParentHeader({ username: author, avatar, avatarShape });
// Focus input
const input = document.getElementById('thread-input');
if (input) input.focus();
},
_setThreadPiPEnabled(enabled) {
const panel = document.getElementById('thread-panel');
const pipBtn = document.getElementById('thread-panel-pip');
if (!panel || !pipBtn) return;
const isOn = !!enabled;
panel.classList.toggle('pip', isOn);
pipBtn.textContent = isOn ? '▣' : '⧉';
pipBtn.title = isOn ? 'Dock thread panel' : 'Pop out thread (PiP)';
pipBtn.setAttribute('aria-pressed', isOn ? 'true' : 'false');
localStorage.setItem('haven_thread_panel_pip', isOn ? '1' : '0');
if (isOn) {
let saved = null;
try { saved = JSON.parse(localStorage.getItem('haven_thread_panel_pip_rect') || 'null'); } catch {}
const minW = 320;
const maxW = Math.min(760, window.innerWidth - 28);
const minH = 240;
const footerOffset = (() => {
const raw = getComputedStyle(document.body).getPropertyValue('--thread-footer-offset');
const v = parseInt(raw, 10);
return Number.isFinite(v) ? v : 0;
})();
const maxH = Math.max(minH, window.innerHeight - footerOffset - 28);
const width = Math.max(minW, Math.min(maxW, (saved && saved.width) || panel.offsetWidth || 420));
const height = Math.max(minH, Math.min(maxH, (saved && saved.height) || panel.offsetHeight || 460));
const defaultLeft = Math.max(0, window.innerWidth - width - 14);
const defaultTop = Math.max(0, window.innerHeight - footerOffset - height - 14);
const left = Math.max(0, Math.min(window.innerWidth - width, (saved && Number.isFinite(saved.left)) ? saved.left : defaultLeft));
const top = Math.max(0, Math.min(window.innerHeight - footerOffset - height, (saved && Number.isFinite(saved.top)) ? saved.top : defaultTop));
panel.style.width = `${Math.round(width)}px`;
panel.style.height = `${Math.round(height)}px`;
panel.style.left = `${Math.round(left)}px`;
panel.style.top = `${Math.round(top)}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
} else {
panel.style.height = '';
panel.style.left = '';
panel.style.top = '';
panel.style.right = '';
panel.style.bottom = '';
}
},
_toggleThreadPiP() {
const panel = document.getElementById('thread-panel');
if (!panel) return;
this._setThreadPiPEnabled(!panel.classList.contains('pip'));
},
_closeThread() {
this._activeThreadParent = null;
this._clearThreadReply();
const panel = document.getElementById('thread-panel');
if (panel) {
panel.style.display = 'none';
panel.dataset.parentId = '';
}
},
_sendThreadMessage() {
const input = document.getElementById('thread-input');
if (!input) return;
const content = input.value.trim();
if (!content) return;
const parentId = this._activeThreadParent;
if (!parentId) return;
const replyTo = this._threadReplyingTo ? this._threadReplyingTo.id : null;
this.socket.emit('send-thread-message', { parentId, content, replyTo }, (resp) => {
if (resp && resp.error) {
this._showToast(resp.error, 'error');
return;
}
this._clearThreadReply();
});
input.value = '';
},
_appendThreadMessage(msg) {
const container = document.getElementById('thread-messages');
if (!container) return;
const color = this._getUserColor(msg.username);
const initial = msg.username.charAt(0).toUpperCase();
let avatarHtml;
if (msg.avatar) {
avatarHtml = `<img class="thread-msg-avatar" src="${this._escapeHtml(msg.avatar)}" alt="${initial}">`;
} else {
avatarHtml = `<div class="thread-msg-avatar thread-msg-avatar-initial" style="background:${color}">${initial}</div>`;
}
const reactionsHtml = this._renderReactions(msg.id, msg.reactions || []);
const replyHtml = msg.replyContext ? this._renderReplyBanner(msg.replyContext) : '';
const canDelete = msg.user_id === this.user.id || this.user.isAdmin || this._canModerate();
const canEdit = msg.user_id === this.user.id;
const iconPair = (emoji, monoSvg) => `<span class="tb-icon tb-icon-emoji" aria-hidden="true">${emoji}</span><span class="tb-icon tb-icon-mono" aria-hidden="true">${monoSvg}</span>`;
const iReact = iconPair('😀', '<svg class="thread-action-react-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke-width="1.8"></circle><path d="M8.5 14.5c1 1.2 2.2 1.8 3.5 1.8s2.5-.6 3.5-1.8" stroke-width="1.8" stroke-linecap="round"></path><circle cx="9.2" cy="10.2" r="1" fill="currentColor" stroke="none"></circle><circle cx="14.8" cy="10.2" r="1" fill="currentColor" stroke="none"></circle></svg>');
const iReply = iconPair('↩️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 8L4 12L10 16" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M20 12H5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iQuote = iconPair('💬', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 7H5v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M19 7h-4v6h4l-2 4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>');
const iEdit = iconPair('✏️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20l4.5-1 9-9-3.5-3.5-9 9L4 20z" stroke-width="1.8" stroke-linejoin="round"></path><path d="M13.5 6.5l3.5 3.5" stroke-width="1.8" stroke-linecap="round"></path></svg>');
const iDelete = iconPair('🗑️', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14" stroke-width="1.8" stroke-linecap="round"></path><path d="M9 7V5h6v2" stroke-width="1.8" stroke-linecap="round"></path><path d="M7 7l1 12h8l1-12" stroke-width="1.8" stroke-linejoin="round"></path></svg>');
const iMore = iconPair('⋯', '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"></circle><circle cx="18" cy="12" r="1.6" fill="currentColor" stroke="none"></circle></svg>');
const threadCoreToolbarBtns = `<button data-thread-action="react" title="React" aria-label="React">${iReact}</button><button data-thread-action="reply" title="Reply">${iReply}</button><button data-thread-action="quote" title="Quote">${iQuote}</button>`;
let threadOverflowToolbarBtns = '';
if (canEdit) threadOverflowToolbarBtns += `<button data-thread-action="edit" title="Edit">${iEdit}</button>`;
if (canDelete) threadOverflowToolbarBtns += `<button data-thread-action="delete" title="Delete">${iDelete}</button>`;
const threadOverflowHtml = threadOverflowToolbarBtns
? `<div class="thread-msg-more"><button class="thread-msg-more-btn" type="button" aria-label="More actions">${iMore}</button><div class="thread-msg-overflow">${threadOverflowToolbarBtns}</div></div>`
: '';
const el = document.createElement('div');
el.className = 'thread-message';
el.dataset.msgId = msg.id;
el.dataset.rawContent = msg.content;
el.innerHTML = `
<div class="thread-msg-row">
${avatarHtml}
<div class="thread-msg-body">
<div class="thread-msg-header">
<span class="thread-msg-author" style="color:${color}">${this._escapeHtml(msg.username)}</span>
<span class="thread-msg-time">${this._formatTime(msg.created_at)}</span>
<span class="thread-msg-header-spacer"></span>
<div class="thread-msg-toolbar">
<div class="msg-toolbar-group">${threadCoreToolbarBtns}</div>
${threadOverflowHtml}
</div>
</div>
${replyHtml}
<div class="thread-msg-content">${this._formatContent(msg.content)}</div>
${reactionsHtml}
</div>
</div>
`;
container.appendChild(el);
container.scrollTop = container.scrollHeight;
},
_updateThreadPreview(parentId, thread) {
const msgEl = document.querySelector(`[data-msg-id="${parentId}"]`);
if (!msgEl) return;
const oldPreview = msgEl.querySelector('.thread-preview');
const newHtml = this._renderThreadPreview(parentId, thread);
if (oldPreview) {
oldPreview.outerHTML = newHtml;
} else if (newHtml) {
// Insert after reactions row, or after message-content
const reactions = msgEl.querySelector('.reactions-row');
const content = msgEl.querySelector('.message-content');
const insertAfter = reactions || content;
if (insertAfter) insertAfter.insertAdjacentHTML('afterend', newHtml);
}
},
// ═══════════════════════════════════════════════════════
// REPLY
// ═══════════════════════════════════════════════════════
@ -1316,6 +1712,38 @@ _clearReply() {
if (bar) bar.style.display = 'none';
},
_quoteMessage(msgEl) {
// Get the raw text content of the message
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.message-content')?.textContent || '';
// Get the author name
let author = msgEl.querySelector('.message-author')?.textContent;
if (!author) {
let prev = msgEl.previousElementSibling;
while (prev) {
const authorEl = prev.querySelector('.message-author');
if (authorEl) { author = authorEl.textContent; break; }
prev = prev.previousElementSibling;
}
}
author = author || 'someone';
// Build the blockquote text — each line prefixed with >
const quotedLines = rawContent.split('\n').map(l => `> ${l}`).join('\n');
const quoteText = `> @${author} wrote:\n${quotedLines}\n`;
const input = document.getElementById('message-input');
// If there's already text, add a newline before the quote
if (input.value) {
input.value += '\n' + quoteText;
} else {
input.value = quoteText;
}
input.focus();
// Trigger input event so textarea auto-resizes
input.dispatchEvent(new Event('input'));
},
// ═══════════════════════════════════════════════════════
// EDIT MESSAGE
// ═══════════════════════════════════════════════════════
@ -1324,7 +1752,7 @@ _startEditMessage(msgEl, msgId) {
// Guard against re-entering edit mode
if (msgEl.classList.contains('editing')) return;
const contentEl = msgEl.querySelector('.message-content');
const contentEl = msgEl.querySelector('.message-content, .thread-msg-content');
if (!contentEl) return;
// Use the stored raw markdown content (set on render and kept in sync on
@ -1346,11 +1774,22 @@ _startEditMessage(msgEl, msgId) {
textarea.maxLength = 2000;
contentEl.appendChild(textarea);
// Track active edit textarea for emoji picker redirection
this._activeEditTextarea = textarea;
const btnRow = document.createElement('div');
btnRow.className = 'edit-actions';
btnRow.innerHTML = `<button class="edit-save-btn">${t('modals.common.save')}</button><button class="edit-cancel-btn">${t('modals.common.cancel')}</button>`;
btnRow.innerHTML = `<button class="edit-emoji-btn" title="${t('app.input_bar.emoji_btn') || 'Emoji'}">😀</button><button class="edit-save-btn">${t('modals.common.save')}</button><button class="edit-cancel-btn">${t('modals.common.cancel')}</button>`;
contentEl.appendChild(btnRow);
// Emoji button in edit bar opens the picker
btnRow.querySelector('.edit-emoji-btn').addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
this._activeEditTextarea = textarea;
this._toggleEmojiPicker();
});
textarea.focus();
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
@ -1358,6 +1797,13 @@ _startEditMessage(msgEl, msgId) {
const cancel = () => {
msgEl.classList.remove('editing');
contentEl.innerHTML = originalHtml;
if (this._activeEditTextarea === textarea) this._activeEditTextarea = null;
// Close emoji picker if it was open for this edit
const picker = document.getElementById('emoji-picker');
if (picker) picker.style.display = 'none';
// Close autocomplete dropdowns
this._hideMentionDropdown();
this._hideEmojiDropdown();
};
btnRow.querySelector('.edit-cancel-btn').addEventListener('click', (e) => {
@ -1388,6 +1834,35 @@ _startEditMessage(msgEl, msgId) {
textarea.addEventListener('keydown', (e) => {
e.stopPropagation();
// Handle @mention and :emoji dropdown navigation in edit mode
const mentionDd = document.getElementById('mention-dropdown');
if (mentionDd && mentionDd.style.display !== 'none') {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
this._navigateMentionDropdown(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
const active = mentionDd.querySelector('.mention-item.active');
if (active) { e.preventDefault(); active.click(); return; }
}
if (e.key === 'Escape') { this._hideMentionDropdown(); return; }
}
const emojiDd = document.getElementById('emoji-dropdown');
if (emojiDd && emojiDd.style.display !== 'none') {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
this._navigateEmojiDropdown(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
const active = emojiDd.querySelector('.emoji-ac-item.active');
if (active) { e.preventDefault(); active.click(); return; }
}
if (e.key === 'Escape') { this._hideEmojiDropdown(); return; }
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
btnRow.querySelector('.edit-save-btn').click();
@ -1398,6 +1873,12 @@ _startEditMessage(msgEl, msgId) {
}
});
// Enable @mention and :emoji autocomplete in edit textarea
textarea.addEventListener('input', () => {
this._checkMentionTrigger(textarea);
this._checkEmojiTrigger(textarea);
});
// Click inside edit area should not bubble to delegation handler
contentEl.addEventListener('click', (e) => {
e.stopPropagation();

View file

@ -7,13 +7,20 @@
class NotificationManager {
constructor() {
this.audioCtx = null;
this.enabled = this._loadPref('haven_notif_enabled', true);
this.enabled = this._loadPref('haven_notif_enabled', false);
this.mentionsEnabled = this._loadPref('haven_notif_mentions_enabled', true);
this.repliesEnabled = this._loadPref('haven_notif_replies_enabled', true);
this.dmEnabled = this._loadPref('haven_notif_dm_enabled', true);
this.volume = this._loadPref('haven_notif_volume', 0.5);
this.mentionVolume = this._loadPref('haven_notif_mention_volume', 0.8);
this.replyVolume = this._loadPref('haven_notif_reply_volume', 0.8);
this.joinVolume = this._loadPref('haven_notif_join_volume', 0.8);
this.leaveVolume = this._loadPref('haven_notif_leave_volume', 0.8);
this.sounds = {
message: this._loadPref('haven_notif_msg_sound', 'ping'),
sent: this._loadPref('haven_notif_sent_sound', 'swoosh'),
mention: this._loadPref('haven_notif_mention_sound', 'bell'),
reply: this._loadPref('haven_notif_reply_sound', 'chime'),
join: this._loadPref('haven_notif_join_sound', 'chime'),
leave: this._loadPref('haven_notif_leave_sound', 'drop'),
announcement: this._loadPref('haven_notif_announcement_sound', 'announcement'),
@ -45,7 +52,7 @@ class NotificationManager {
// ── Synth Tone Engine ───────────────────────────────────
_playTone(frequencies, durations, type = 'sine') {
if (!this.enabled || this.volume <= 0) return;
if (this.volume <= 0) return;
try {
const ctx = this._getCtx();
const masterGain = ctx.createGain();
@ -95,7 +102,7 @@ class NotificationManager {
// ── Custom Sound File Playback ──────────────────────────
_playFile(url) {
if (!this.enabled || this.volume <= 0) return;
if (this.volume <= 0) return;
try {
let audio = this._audioCache[url];
if (!audio) {
@ -133,14 +140,28 @@ class NotificationManager {
// ── Public API ──────────────────────────────────────────
play(event) {
play(event, opts) {
// Per-type opt-in: mentions, replies, DMs bypass master toggle if their own toggle is on
if (opts && opts.isMention && this.mentionsEnabled) { /* allowed */ }
else if (opts && opts.isReply && this.repliesEnabled) { /* allowed */ }
else if (opts && opts.isDm && this.dmEnabled) { /* allowed */ }
// Regular message & announcement sounds are gated by the master toggle;
// everything else (sent, join, leave) always plays
else if ((event === 'message' || event === 'announcement') && !this.enabled) return;
const sound = this.sounds[event];
if (!sound || sound === 'none') return;
// Use mention volume if this is a mention event
// Use per-event volume if set
const origVol = this.volume;
if (event === 'mention') {
this.volume = this.mentionVolume;
} else if (event === 'reply') {
this.volume = this.replyVolume;
} else if (event === 'join') {
this.volume = this.joinVolume;
} else if (event === 'leave') {
this.volume = this.leaveVolume;
}
// Custom uploaded sound (format: "custom:soundname")
@ -148,7 +169,7 @@ class NotificationManager {
const name = sound.substring(7);
// Look up URL from any notification select, or from app's custom sounds cache
let url = null;
const selIds = ['notif-msg-sound', 'notif-sent-sound', 'notif-mention-sound', 'notif-join-sound', 'notif-leave-sound'];
const selIds = ['notif-msg-sound', 'notif-sent-sound', 'notif-mention-sound', 'notif-reply-sound', 'notif-join-sound', 'notif-leave-sound'];
for (const id of selIds) {
const sel = document.getElementById(id);
if (!sel) continue;
@ -173,7 +194,7 @@ class NotificationManager {
/** Play a named tone directly (bypasses event→sound mapping). Used for UI cues. */
playDirect(toneName) {
if (!this.enabled || this.volume <= 0) return;
if (this.volume <= 0) return;
if (typeof this[toneName] === 'function') this[toneName]();
}
@ -192,6 +213,21 @@ class NotificationManager {
this._savePref('haven_notif_mention_volume', this.mentionVolume);
}
setReplyVolume(val) {
this.replyVolume = Math.max(0, Math.min(1, val));
this._savePref('haven_notif_reply_volume', this.replyVolume);
}
setJoinVolume(val) {
this.joinVolume = Math.max(0, Math.min(1, val));
this._savePref('haven_notif_join_volume', this.joinVolume);
}
setLeaveVolume(val) {
this.leaveVolume = Math.max(0, Math.min(1, val));
this._savePref('haven_notif_leave_volume', this.leaveVolume);
}
setSound(event, sound) {
this.sounds[event] = sound;
this._savePref(`haven_notif_${event}_sound`, sound);

View file

@ -8,11 +8,39 @@ class ServerManager {
this.servers = this._load();
this.statusCache = new Map();
this.checkInterval = null;
this.selfFingerprint = null;
this.selfFingerprintReady = this._fetchSelfFingerprint();
}
/** Fetch the current server's fingerprint so we can hide "self" from the sidebar. */
async _fetchSelfFingerprint() {
try {
const res = await fetch('/api/health');
if (res.ok) {
const data = await res.json();
if (data.fingerprint) this.selfFingerprint = data.fingerprint;
}
} catch {}
}
_load() {
try {
return JSON.parse(localStorage.getItem('haven_servers') || '[]');
const raw = JSON.parse(localStorage.getItem('haven_servers') || '[]');
// Normalize URLs on load to dedup legacy entries while preserving
// subpath-hosted servers like https://host/community.
const seen = new Set();
const deduped = [];
for (const s of raw) {
const normalizedUrl = this._normalizeUrl(s?.url || '');
if (!normalizedUrl || seen.has(normalizedUrl)) continue;
s.url = normalizedUrl;
seen.add(normalizedUrl);
deduped.push(s);
}
if (deduped.length !== raw.length) {
localStorage.setItem('haven_servers', JSON.stringify(deduped));
}
return deduped;
} catch { return []; }
}
@ -21,9 +49,15 @@ class ServerManager {
}
add(name, url, icon = null) {
url = url.replace(/\/+$/, '');
if (!/^https?:\/\//.test(url)) url = 'https://' + url;
if (this.servers.find(s => s.url === url)) return false;
url = this._normalizeUrl(url);
if (this.servers.find(s => this._normalizeUrl(s.url) === url)) return false;
// User explicitly adding — clear from removed set so sync won't fight it
const removed = this._loadRemoved();
if (removed.has(url)) {
removed.delete(url);
this._saveRemoved(removed);
}
this.servers.push({ name, url, icon, addedAt: Date.now() });
this._save();
@ -32,7 +66,8 @@ class ServerManager {
}
update(url, updates) {
const server = this.servers.find(s => s.url === url);
const normalizedUrl = this._normalizeUrl(url);
const server = this.servers.find(s => this._normalizeUrl(s.url) === normalizedUrl);
if (!server) return false;
if (updates.name !== undefined) server.name = updates.name;
if (updates.icon !== undefined) server.icon = updates.icon;
@ -41,8 +76,24 @@ class ServerManager {
}
remove(url) {
this.servers = this.servers.filter(s => s.url !== url);
this.statusCache.delete(url);
const normalizedUrl = this._normalizeUrl(url);
this.servers = this.servers.filter(s => this._normalizeUrl(s.url) !== normalizedUrl);
this.statusCache.delete(normalizedUrl);
this._save();
this.markRemoved(normalizedUrl);
}
/** Reorder servers by an array of URLs in the desired order. */
reorder(orderedUrls) {
const map = new Map(this.servers.map(s => [s.url, s]));
const reordered = [];
for (const url of orderedUrls) {
const s = map.get(url);
if (s) { reordered.push(s); map.delete(url); }
}
// Append any servers not in the ordered list (shouldn't happen, but safe)
for (const s of map.values()) reordered.push(s);
this.servers = reordered;
this._save();
}
@ -54,14 +105,12 @@ class ServerManager {
}
async checkServer(url) {
const normalizedUrl = this._normalizeUrl(url);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
// Use only the origin for health checks — if someone stored a URL
// like https://example.com/app, we don't want /app/api/health (404).
let healthBase;
try { healthBase = new URL(url).origin; } catch { healthBase = url; }
const healthBase = normalizedUrl;
const res = await fetch(`${healthBase}/api/health`, {
signal: controller.signal,
@ -71,18 +120,40 @@ class ServerManager {
if (res.ok) {
const data = await res.json();
this.statusCache.set(url, {
const discoveredIcon = data.icon ? new URL(data.icon, `${healthBase}/`).toString() : null;
this.statusCache.set(normalizedUrl, {
online: true,
name: data.name || url,
icon: data.icon ? `${url}${data.icon}` : null,
name: data.name || normalizedUrl,
icon: discoveredIcon,
version: data.version,
fingerprint: data.fingerprint || null,
checkedAt: Date.now()
});
// Persist discovered icon to the server entry so it survives
// across page reloads and offline periods
if (discoveredIcon) {
const entry = this.servers.find(s => this._normalizeUrl(s.url) === normalizedUrl);
if (entry) {
// Always update the icon URL (server may have changed its icon)
if (entry.icon !== discoveredIcon) {
entry.icon = discoveredIcon;
entry.iconData = null; // clear stale thumbnail
this._save();
}
// Generate a small base64 thumbnail so the icon travels
// with the encrypted sync bundle across servers
if (!entry.iconData) {
this._fetchIconThumbnail(discoveredIcon).then(dataUrl => {
if (dataUrl) { entry.iconData = dataUrl; this._save(); }
});
}
}
}
} else {
this.statusCache.set(url, { online: false, checkedAt: Date.now() });
this.statusCache.set(normalizedUrl, { online: false, checkedAt: Date.now() });
}
} catch {
this.statusCache.set(url, { online: false, checkedAt: Date.now() });
this.statusCache.set(normalizedUrl, { online: false, checkedAt: Date.now() });
}
}
@ -98,4 +169,194 @@ class ServerManager {
stopPolling() {
if (this.checkInterval) clearInterval(this.checkInterval);
}
// ── Encrypted server-side sync ───────────────────────
// Stores the server list as an AES-256-GCM blob on each Haven server.
// wrappingHex: the 64-char hex string from HavenE2E.deriveWrappingKey()
/** Fetch a remote icon and shrink it to a tiny base64 data URL. */
async _fetchIconThumbnail(iconUrl) {
try {
const res = await fetch(iconUrl, { mode: 'cors', signal: AbortSignal.timeout(5000) });
if (!res.ok) return null;
const blob = await res.blob();
if (!blob.type.startsWith('image/')) return null;
const bmp = await createImageBitmap(blob);
const size = 48;
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.drawImage(bmp, 0, 0, size, size);
bmp.close();
return canvas.toDataURL('image/png');
} catch { return null; }
}
async syncWithServer(token, wrappingHex) {
if (!token || !wrappingHex) return;
try {
// 1. Fetch the encrypted blob from the server
const res = await fetch('/api/auth/user-servers', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) return;
const { blob } = await res.json();
// 2. Decrypt server-side list (if any)
let remoteServers = [];
if (blob) {
try {
const decrypted = await this._decryptBlob(blob, wrappingHex);
remoteServers = JSON.parse(decrypted);
if (!Array.isArray(remoteServers)) remoteServers = [];
} catch {
// Decryption failed — blob was encrypted with a different password
// or is corrupted. Start fresh from localStorage.
console.warn('[ServerSync] Could not decrypt server blob — using local list');
}
}
// 3. Load removed-servers set (removals are local-only, never synced)
const removed = this._loadRemoved();
// 4. Merge: union by URL, filtering out locally-removed servers
const localUrls = new Set(this.servers.map(s => this._normalizeUrl(s.url)));
const remoteUrls = new Set(remoteServers.map(s => this._normalizeUrl(s.url)));
let changed = false;
// Add remote servers we don't have locally (and haven't removed)
for (const rs of remoteServers) {
const normalizedUrl = this._normalizeUrl(rs.url);
if (!localUrls.has(rs.url) && !localUrls.has(normalizedUrl)
&& !removed.has(rs.url) && !removed.has(normalizedUrl)) {
rs.url = normalizedUrl; // store the normalized form
this.servers.push(rs);
localUrls.add(normalizedUrl); // prevent duplicate adds within same sync
changed = true;
}
}
// Check if we have servers the remote doesn't
for (const ls of this.servers) {
if (!remoteUrls.has(ls.url)) changed = true;
}
// 5. Save merged list locally
if (changed) this._save();
// 6. Push updated encrypted blob back if our list is longer
if (changed || !blob) {
await this._pushToServer(token, wrappingHex);
}
} catch (err) {
console.warn('[ServerSync] Sync failed:', err.message);
}
}
async _pushToServer(token, wrappingHex) {
try {
const payload = JSON.stringify(this.servers.map(s => ({
url: s.url, name: s.name, icon: s.icon, iconData: s.iconData || null, addedAt: s.addedAt
})));
const blob = await this._encryptBlob(payload, wrappingHex);
await fetch('/api/auth/user-servers', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ blob })
});
} catch (err) {
console.warn('[ServerSync] Push failed:', err.message);
}
}
// ── Crypto helpers (AES-256-GCM with PBKDF2) ─────────
async _encryptBlob(plaintext, wrappingHex) {
const keyBytes = this._hexToBytes(wrappingHex);
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await this._deriveAESKey(keyBytes, salt);
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext)
);
// Format: base64(salt + iv + ciphertext)
const combined = new Uint8Array(16 + 12 + ct.byteLength);
combined.set(salt, 0);
combined.set(iv, 16);
combined.set(new Uint8Array(ct), 28);
return btoa(String.fromCharCode(...combined));
}
async _decryptBlob(blob, wrappingHex) {
const keyBytes = this._hexToBytes(wrappingHex);
const raw = Uint8Array.from(atob(blob), c => c.charCodeAt(0));
const salt = raw.slice(0, 16);
const iv = raw.slice(16, 28);
const ct = raw.slice(28);
const key = await this._deriveAESKey(keyBytes, salt);
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
return new TextDecoder().decode(pt);
}
async _deriveAESKey(keyBytes, salt) {
const raw = await crypto.subtle.importKey('raw', keyBytes, 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 100_000 },
raw,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
_hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
/** Normalize a Haven server URL to its base path (strips /app(.html), query, hash, trailing slash). */
_normalizeUrl(url) {
url = String(url || '').trim();
if (!url) return '';
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
try {
const parsed = new URL(url);
parsed.hash = '';
parsed.search = '';
let pathname = parsed.pathname || '/';
pathname = pathname.replace(/\/+$/, '') || '/';
pathname = pathname.replace(/\/app(?:\.html)?$/i, '') || '/';
pathname = pathname.replace(/\/+$/, '') || '/';
return pathname === '/' ? parsed.origin : parsed.origin + pathname;
} catch {
return url.replace(/\/+$/, '');
}
}
// ── Removed-servers tracking (local-only) ─────────────
_loadRemoved() {
try {
return new Set(JSON.parse(localStorage.getItem('haven_servers_removed') || '[]'));
} catch { return new Set(); }
}
_saveRemoved(set) {
localStorage.setItem('haven_servers_removed', JSON.stringify([...set]));
}
markRemoved(url) {
const normalizedUrl = this._normalizeUrl(url);
const removed = this._loadRemoved();
if (normalizedUrl) removed.add(normalizedUrl);
this._saveRemoved(removed);
}
}

View file

@ -179,6 +179,14 @@ class VoiceManager {
}
});
// Server relays speaking state from any voice user (including self)
this.socket.on('voice-speaking', (data) => {
if (data && data.userId != null) {
const uid = data.userId === this.localUserId ? 'self' : data.userId;
if (this.onTalkingChange) this.onTalkingChange(uid, !!data.speaking);
}
});
// Someone left voice
this.socket.on('voice-user-left', (data) => {
if (this.onVoiceLeave && data && data.user) {
@ -218,6 +226,15 @@ class VoiceManager {
if (this.onAfkMove) this.onAfkMove(data.channelCode);
});
// Kicked from voice because user joined from another client/tab
this.socket.on('voice-kicked', (data) => {
if (!data || !data.channelCode) return;
// Only act if we're currently in the channel we got kicked from
if (this.currentChannel !== data.channelCode) return;
this.leave();
if (this.onVoiceKicked) this.onVoiceKicked(data.channelCode, data.reason);
});
// Someone started screen sharing
this.socket.on('screen-share-started', (data) => {
this.screenSharers.add(data.userId);
@ -1686,7 +1703,7 @@ class VoiceManager {
if (wasTalking) {
wasTalking = false;
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
if (this.onTalkingChange) this.onTalkingChange('self', false);
if (this.socket && this.inVoice) this.socket.emit('voice-speaking', { speaking: false });
}
return;
}
@ -1700,7 +1717,7 @@ class VoiceManager {
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
if (!wasTalking) {
wasTalking = true;
if (this.onTalkingChange) this.onTalkingChange('self', true);
if (this.socket && this.inVoice) this.socket.emit('voice-speaking', { speaking: true });
}
// Notify server of voice activity for AFK tracking (throttled to once per 15s)
if (this.socket && this.inVoice && (!this._lastVoiceSpeakPing || Date.now() - this._lastVoiceSpeakPing > 15000)) {
@ -1711,7 +1728,7 @@ class VoiceManager {
holdTimer = setTimeout(() => {
wasTalking = false;
holdTimer = null;
if (this.onTalkingChange) this.onTalkingChange('self', false);
if (this.socket && this.inVoice) this.socket.emit('voice-speaking', { speaking: false });
}, HOLD_MS);
}
}, 60);
@ -1723,6 +1740,7 @@ class VoiceManager {
clearInterval(this._localTalkInterval);
this._localTalkInterval = null;
this._localTalkAnalyser = null;
if (this.socket && this.inVoice) this.socket.emit('voice-speaking', { speaking: false });
if (this.onTalkingChange) this.onTalkingChange('self', false);
}
}

View file

@ -201,6 +201,7 @@
"msg_toolbar": {
"react": "Reagieren",
"reply": "Antworten",
"quote": "Zitieren",
"pin": "Anheften",
"unpin": "Lösen",
"edit": "Bearbeiten",

View file

@ -158,6 +158,9 @@
"bio_updated": "Bio updated",
"display_name_changed": "Display name changed to \"{{name}}\"",
"channel_code_copied": "Channel code copied!",
"channel_link_copied": "Channel link copied",
"message_link_copied": "Message link copied",
"channel_link_unavailable": "Channel not available on this server",
"channel_muted": "Channel muted",
"channel_unmuted": "Channel unmuted",
"left_channel": "Left #{{name}}",
@ -201,6 +204,8 @@
"msg_toolbar": {
"react": "React",
"reply": "Reply",
"quote": "Quote",
"copy_link": "Copy link to message",
"pin": "Pin",
"unpin": "Unpin",
"edit": "Edit",
@ -377,7 +382,8 @@
"transfer_admin": "Transfer Admin",
"manage_roles": "Manage Roles",
"manage_server": "Manage Server",
"delete_channel": "Delete Channels"
"delete_channel": "Delete Channels",
"read_only_override": "Read-Only Override"
},
"slowmode": {
"wait": "Slow mode — wait {{seconds}}s before sending another message",
@ -403,7 +409,7 @@
"encryption_options": "Encryption options",
"verify_encryption": "Verify Encryption",
"reset_encryption": "Reset Encryption Keys",
"search_placeholder": "Search messages...",
"search_placeholder": "Search... from:user in:#channel has:image",
"update_text": "Update v{{version}}",
"update_title": "Haven v{{remote}} is available (you have v{{local}}). Click to view.",
"get_desktop_app": "Get the Desktop App",
@ -686,6 +692,8 @@
"msg_sent": "Msg Sent",
"mention_vol": "@Mention Vol",
"mentions": "@Mentions",
"reply_vol": "Reply Vol",
"replies": "Replies",
"user_joined": "User Joined",
"user_left": "User Left",
"hide_voice_panel": "Hide Voice Panel",
@ -861,6 +869,7 @@
"modmode_reset_btn": "↺ Reset Layout",
"save_btn": "Save Settings",
"save_hint": "Changes only apply when you click Save. Close (✕) to cancel.",
"save_notice": "Changes below require clicking <strong>Save Settings</strong> to apply.",
"login_title_hint": "Custom title displayed on the login screen below the HAVEN logo.",
"server_icon_hint": "Server icon (square, max 2 MB)",
"default_theme_hint": "New users see this theme on first visit. They can still pick their own.",
@ -1477,6 +1486,7 @@
"channel": {
"mute": "Mute Channel",
"unmute": "Unmute Channel",
"copy_link": "Copy Channel Link",
"join_voice": "Join Voice",
"disconnect_voice": "Disconnect Voice",
"leave": "Leave Channel",
@ -1493,6 +1503,7 @@
"dm": {
"mute": "Mute DM",
"unmute": "Unmute DM",
"copy_link": "Copy DM Link",
"delete": "Delete DM"
}
},

View file

@ -201,6 +201,7 @@
"msg_toolbar": {
"react": "Reaccionar",
"reply": "Responder",
"quote": "Citar",
"pin": "Fijar",
"unpin": "Desfijar",
"edit": "Editar",

View file

@ -201,6 +201,7 @@
"msg_toolbar": {
"react": "Réagir",
"reply": "Répondre",
"quote": "Citer",
"pin": "Épingler",
"unpin": "Désépingler",
"edit": "Modifier",

View file

@ -201,6 +201,7 @@
"msg_toolbar": {
"react": "Реакция",
"reply": "Ответить",
"quote": "Цитировать",
"pin": "Закрепить",
"unpin": "Открепить",
"edit": "Изменить",

View file

@ -201,6 +201,7 @@
"msg_toolbar": {
"react": "回应",
"reply": "回复",
"quote": "引用",
"pin": "置顶",
"unpin": "取消置顶",
"edit": "编辑",

769
server.js
View file

@ -114,6 +114,7 @@ app.use(helmet({
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'self'"], // allow mobile app iframe, block third-party clickjacking
...(process.env.FORCE_HTTP?.toLowerCase() === 'true' ? { upgradeInsecureRequests: null } : {}), // helmet 8.x auto-appends upgrade-insecure-requests; disable when FORCE_HTTP=true
}
},
crossOriginEmbedderPolicy: false, // needed for WebRTC
@ -158,7 +159,17 @@ app.use('/uploads', express.static(UPLOADS_DIR, {
setHeaders: (res, filePath) => {
// Force download for non-image files (prevents HTML/SVG execution in browser)
const ext = path.extname(filePath).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
// Allow cross-origin access for images (needed for server icon pulling).
// CORP override is required because helmet defaults to 'same-origin', which
// would otherwise block cross-origin <img> loads even with ACAO set.
// Vary: Origin prevents a non-CORS cached response from being reused for a
// CORS request (which is what causes the "No 'Access-Control-Allow-Origin'
// header is present" error on a cached image).
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Vary', 'Origin');
} else {
res.setHeader('Content-Disposition', 'attachment');
}
}
@ -535,7 +546,27 @@ app.get('/', (req, res) => {
app.get('/app', (req, res) => {
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(path.join(__dirname, 'public', 'app.html'));
// Inject current version into cache-busting query strings so client
// assets are never served stale after an update (especially in Electron).
const ver = require('./package.json').version;
let html = fs.readFileSync(path.join(__dirname, 'public', 'app.html'), 'utf8');
html = html.replace(/(\?v=)[^"']*/g, `$1${ver}`);
res.type('html').send(html);
});
// ── Vanity invite link (/invite/:code) ────────────────
app.get('/invite/:vanityCode', (req, res) => {
const vanityCode = req.params.vanityCode;
if (!vanityCode || typeof vanityCode !== 'string' || !/^[a-zA-Z0-9_-]{3,32}$/.test(vanityCode)) {
return res.status(400).send('Invalid invite link');
}
const { getDb } = require('./src/database');
const row = getDb().prepare("SELECT value FROM server_settings WHERE key = 'vanity_code'").get();
if (!row || row.value !== vanityCode) {
return res.status(404).send('Invite link not found or expired');
}
// Redirect to /app with the vanity code as a query param — the frontend will auto-join
res.redirect(`/app?invite=${encodeURIComponent(vanityCode)}`);
});
app.get('/games/flappy', (req, res) => {
@ -565,8 +596,11 @@ app.get('/api/donors', (req, res) => {
// ── Health check (CORS allowed for multi-server status pings) ──
app.get('/api/health', (req, res) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Cross-Origin-Resource-Policy', 'cross-origin');
res.set('Vary', 'Origin');
let name = process.env.SERVER_NAME || 'Haven';
let icon = null;
let fingerprint = null;
try {
const { getDb } = require('./src/database');
const db = getDb();
@ -574,11 +608,14 @@ app.get('/api/health', (req, res) => {
if (row && row.value) name = row.value;
const iconRow = db.prepare("SELECT value FROM server_settings WHERE key = 'server_icon'").get();
if (iconRow && iconRow.value) icon = iconRow.value;
const fpRow = db.prepare("SELECT value FROM server_settings WHERE key = 'server_fingerprint'").get();
if (fpRow && fpRow.value) fingerprint = fpRow.value;
} catch {}
res.json({
status: 'online',
name,
icon
icon,
fingerprint
// version intentionally omitted — don't fingerprint the server for attackers
});
});
@ -1119,6 +1156,312 @@ app.post('/api/upload-server-icon', uploadLimiter, (req, res) => {
});
});
// ── Role icon upload (admin only, image only, max 512 KB) ──
app.post('/api/upload-role-icon', uploadLimiter, (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const user = token ? verifyToken(token) : null;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
if (!verifyAdminFromDb(user) && !userHasPermission(user.id, 'manage_roles')) {
return res.status(403).json({ error: 'Admin or manage_roles permission required' });
}
upload.single('icon')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
if (req.file.size > 512 * 1024) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Role icon must be under 512 KB' });
}
try {
const fd = fs.openSync(req.file.path, 'r');
const hdr = Buffer.alloc(12);
fs.readSync(fd, hdr, 0, 12, 0);
fs.closeSync(fd);
let validMagic = false;
if (req.file.mimetype === 'image/jpeg') validMagic = hdr[0] === 0xFF && hdr[1] === 0xD8 && hdr[2] === 0xFF;
else if (req.file.mimetype === 'image/png') validMagic = hdr[0] === 0x89 && hdr[1] === 0x50 && hdr[2] === 0x4E && hdr[3] === 0x47;
else if (req.file.mimetype === 'image/gif') validMagic = hdr.slice(0, 6).toString().startsWith('GIF8');
else if (req.file.mimetype === 'image/webp') validMagic = hdr.slice(0, 4).toString() === 'RIFF' && hdr.slice(8, 12).toString() === 'WEBP';
if (!validMagic) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'Invalid image' }); }
} catch { try { fs.unlinkSync(req.file.path); } catch {} return res.status(400).json({ error: 'Failed to validate' }); }
const iconUrl = `/uploads/${req.file.filename}`;
res.json({ path: iconUrl });
});
});
// ── Admin: Server backup download (admin only) ──
// Configurable per-section via ?include=channels,users,settings,messages,files
// Backwards-compat: ?mode=structure → channels,users,settings ;
// ?mode=full → channels,users,settings,messages,files
// Token may be passed via ?token=... so the browser can trigger a normal download.
const ALL_BACKUP_SECTIONS = ['channels', 'users', 'settings', 'messages', 'files'];
app.get('/api/admin/backup', (req, res) => {
const token = req.query.token || req.headers.authorization?.split(' ')[1];
const user = token ? verifyToken(token) : null;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
if (!verifyAdminFromDb(user)) return res.status(403).json({ error: 'Admin only' });
const AdmZip = require('adm-zip');
// Resolve which sections to include
let include = [];
if (typeof req.query.include === 'string' && req.query.include.trim()) {
include = req.query.include.split(',')
.map(s => s.trim().toLowerCase())
.filter(s => ALL_BACKUP_SECTIONS.includes(s));
} else if (req.query.mode === 'full') {
include = ALL_BACKUP_SECTIONS.slice();
} else {
// default / mode=structure
include = ['channels', 'users', 'settings'];
}
if (!include.length) {
return res.status(400).json({ error: 'Pick at least one section to back up' });
}
const has = (s) => include.includes(s);
// The restore endpoint only accepts mode='full' — set it when the backup
// contains both the live DB and uploads, since that's what restore requires.
const mode = (has('messages') && has('files')) ? 'full' : 'partial';
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `haven-backup-${mode === 'full' ? 'full' : include.join('-')}-${ts}.zip`;
let tmpDb = null;
try {
const { getDb } = require('./src/database');
const db = getDb();
const zip = new AdmZip();
const manifest = {
app: 'haven',
version: require('./package.json').version,
exportedAt: new Date().toISOString(),
mode,
include,
serverName: process.env.SERVER_NAME || 'Haven',
};
zip.addFile('manifest.json', Buffer.from(JSON.stringify(manifest, null, 2)));
// Structure JSON — collect tables per selected sections
const structureTables = [];
if (has('channels')) structureTables.push('channels', 'roles', 'role_permissions', 'user_roles', 'channel_members');
if (has('users')) structureTables.push('users');
if (has('settings')) structureTables.push('server_settings', 'whitelist');
if (structureTables.length) {
const data = {};
for (const tbl of structureTables) {
try { data[tbl] = db.prepare(`SELECT * FROM ${tbl}`).all(); }
catch { data[tbl] = []; }
}
// Strip secrets from users — passwords, TOTP, recovery codes never leave the server
if (data.users) {
data.users = data.users.map(u => {
const safe = { ...u };
delete safe.password_hash;
delete safe.password_version;
delete safe.totp_secret;
delete safe.totp_backup_codes;
delete safe.recovery_codes_hash;
delete safe.recovery_codes;
delete safe.email;
return safe;
});
}
// Strip vanity codes / invite codes from server_settings
if (data.server_settings) {
const SENSITIVE_KEYS = new Set(['vanity_code', 'server_invite_code']);
data.server_settings = data.server_settings.filter(r => !SENSITIVE_KEYS.has(r.key));
}
zip.addFile('structure.json', Buffer.from(JSON.stringify(data, null, 2)));
}
// Messages — include the full DB snapshot so restore can rebuild everything.
// (Cherry-picking message tables would defeat referential integrity.)
if (has('messages')) {
tmpDb = path.join(DATA_DIR, `.backup-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch {}
const safePath = tmpDb.replace(/'/g, "''");
db.prepare(`VACUUM INTO '${safePath}'`).run();
zip.addLocalFile(tmpDb, '', 'haven.db');
}
// Files — uploaded attachments / icons / banners / sounds
if (has('files') && fs.existsSync(UPLOADS_DIR)) {
const walk = (dir, rel) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'deleted-attachments') continue;
const full = path.join(dir, entry.name);
const sub = rel ? `${rel}/${entry.name}` : entry.name;
try {
if (entry.isFile()) zip.addLocalFile(full, `uploads${rel ? '/' + rel : ''}`);
else if (entry.isDirectory()) walk(full, sub);
} catch {}
}
};
walk(UPLOADS_DIR, '');
}
const buf = zip.toBuffer();
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buf);
} catch (err) {
console.error('[Backup] Failed:', err);
if (!res.headersSent) res.status(500).json({ error: 'Backup failed: ' + err.message });
} finally {
if (tmpDb) { try { fs.unlinkSync(tmpDb); } catch {} }
}
});
// ── Admin: Server backup restore (admin only, full backups only) ──
// Stages the uploaded backup, then schedules a process exit so the
// supervisor (Docker / systemd / installer service) restarts the server
// with the restored DB and uploads in place. The pre-restore data is
// preserved at haven.db.pre-restore / uploads.pre-restore for one cycle.
const restoreUpload = multer({
dest: path.join(DATA_DIR, 'tmp-restore'),
limits: { fileSize: 4 * 1024 * 1024 * 1024 },
});
app.post('/api/admin/restore', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const user = token ? verifyToken(token) : null;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
if (!verifyAdminFromDb(user)) return res.status(403).json({ error: 'Admin only' });
const tmpDir = path.join(DATA_DIR, 'tmp-restore');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
restoreUpload.single('backup')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' });
const cleanupTmp = () => { try { fs.unlinkSync(req.file.path); } catch {} };
try {
const AdmZip = require('adm-zip');
const zip = new AdmZip(req.file.path);
const entries = zip.getEntries();
const manifestEntry = entries.find(e => e.entryName === 'manifest.json');
if (!manifestEntry) {
cleanupTmp();
return res.status(400).json({ error: 'Invalid backup: missing manifest.json' });
}
let manifest;
try { manifest = JSON.parse(manifestEntry.getData().toString('utf8')); }
catch {
cleanupTmp();
return res.status(400).json({ error: 'Invalid backup: corrupt manifest.json' });
}
if (manifest.app !== 'haven') {
cleanupTmp();
return res.status(400).json({ error: 'Not a Haven backup file' });
}
if (manifest.mode !== 'full') {
cleanupTmp();
return res.status(400).json({
error: 'Only full backups can be restored automatically. Structure-only backups must be re-imported manually.',
});
}
const dbEntry = entries.find(e => e.entryName === 'haven.db');
if (!dbEntry) {
cleanupTmp();
return res.status(400).json({ error: 'Invalid full backup: missing haven.db' });
}
// Stage DB
const stagedDb = DB_PATH + '.restore';
fs.writeFileSync(stagedDb, dbEntry.getData());
// Stage uploads
const stagedUploads = UPLOADS_DIR + '.restore';
if (fs.existsSync(stagedUploads)) {
fs.rmSync(stagedUploads, { recursive: true, force: true });
}
const uploadEntries = entries.filter(e => e.entryName.startsWith('uploads/') && !e.isDirectory);
if (uploadEntries.length > 0) {
fs.mkdirSync(stagedUploads, { recursive: true });
for (const ue of uploadEntries) {
const rel = ue.entryName.slice('uploads/'.length);
if (!rel || rel.includes('..')) continue;
const dest = path.join(stagedUploads, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, ue.getData());
}
}
cleanupTmp();
res.json({
ok: true,
message: 'Backup staged. Server will restart in ~2 seconds to apply. If the server does not come back up, your hosting setup may not auto-restart — start Haven manually.',
scheduled: true,
});
// Apply swap and exit so the supervisor restarts us cleanly
setTimeout(() => {
console.log('🔄 Applying staged backup restore and restarting...');
try {
if (fs.existsSync(stagedDb)) {
try { fs.copyFileSync(DB_PATH, DB_PATH + '.pre-restore'); } catch {}
// Remove stale WAL/SHM so SQLite reopens against the restored file
try { fs.unlinkSync(DB_PATH + '-wal'); } catch {}
try { fs.unlinkSync(DB_PATH + '-shm'); } catch {}
fs.renameSync(stagedDb, DB_PATH);
}
if (fs.existsSync(stagedUploads)) {
const oldUploads = UPLOADS_DIR + '.pre-restore';
if (fs.existsSync(oldUploads)) fs.rmSync(oldUploads, { recursive: true, force: true });
if (fs.existsSync(UPLOADS_DIR)) fs.renameSync(UPLOADS_DIR, oldUploads);
fs.renameSync(stagedUploads, UPLOADS_DIR);
}
} catch (e) {
console.error('[Restore] Swap failed:', e);
}
process.exit(0);
}, 1500);
} catch (e) {
cleanupTmp();
console.error('[Restore] Failed:', e);
if (!res.headersSent) res.status(500).json({ error: 'Restore failed: ' + e.message });
}
});
});
// ── Server banner upload (admin only, image only, max 4 MB) ──
app.post('/api/upload-server-banner', uploadLimiter, (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const user = token ? verifyToken(token) : null;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
if (!verifyAdminFromDb(user)) return res.status(403).json({ error: 'Admin only' });
upload.single('image')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
if (req.file.size > 4 * 1024 * 1024) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Server banner must be under 4 MB' });
}
try {
const fd = fs.openSync(req.file.path, 'r');
const hdr = Buffer.alloc(12);
fs.readSync(fd, hdr, 0, 12, 0);
fs.closeSync(fd);
const isJpeg = hdr[0] === 0xFF && hdr[1] === 0xD8 && hdr[2] === 0xFF;
const isPng = hdr[0] === 0x89 && hdr[1] === 0x50 && hdr[2] === 0x4E && hdr[3] === 0x47;
const isGif = hdr.slice(0, 6).toString().startsWith('GIF8');
const isWebp = hdr.slice(0, 4).toString() === 'RIFF' && hdr.slice(8, 12).toString() === 'WEBP';
if (!isJpeg && !isPng && !isGif && !isWebp) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'Invalid image — only JPG, PNG, GIF, or WebP' }); }
} catch { try { fs.unlinkSync(req.file.path); } catch {} return res.status(400).json({ error: 'Failed to validate' }); }
const bannerUrl = `/uploads/${req.file.filename}`;
const { getDb } = require('./src/database');
getDb().prepare("INSERT OR REPLACE INTO server_settings (key, value) VALUES ('server_banner', ?)").run(bannerUrl);
res.json({ url: bannerUrl });
});
});
// ── GIF endpoint rate limiting (per IP) ──────────────────
const gifLimitStore = new Map();
function gifLimiter(req, res, next) {
@ -1702,6 +2045,334 @@ app.post('/api/webhooks/:token', webhookLimiter, express.json({ limit: '64kb' })
res.status(200).json({ success: true, message_id: result.lastInsertRowid });
});
// ── Bot: Delete a message in the webhook's channel ──────
app.delete('/api/webhooks/:token/messages/:messageId', webhookLimiter, (req, res) => {
const { getDb } = require('./src/database');
const db = getDb();
const { token, messageId } = req.params;
const webhook = getWebhookByToken(token);
if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' });
const mid = parseInt(messageId, 10);
if (!Number.isInteger(mid) || mid < 1) return res.status(400).json({ error: 'Invalid message ID' });
const msg = db.prepare('SELECT id, content, channel_id FROM messages WHERE id = ? AND channel_id = ?').get(mid, webhook.channel_id);
if (!msg) return res.status(404).json({ error: 'Message not found in this channel' });
try {
db.prepare('DELETE FROM pinned_messages WHERE message_id = ?').run(mid);
db.prepare('DELETE FROM reactions WHERE message_id = ?').run(mid);
db.prepare('DELETE FROM messages WHERE id = ?').run(mid);
} catch (err) {
console.error('Bot delete message error:', err);
return res.status(500).json({ error: 'Failed to delete message' });
}
// Move any uploaded attachments to the deleted folder
const uploadRe = /\/uploads\/((?!deleted-attachments)[\w\-.]+)/g;
let m;
while ((m = uploadRe.exec(msg.content || '')) !== null) {
const src = path.join(uploadDir, m[1]);
const dst = path.join(DELETED_ATTACHMENTS_DIR, m[1]);
if (fs.existsSync(src)) {
try { fs.renameSync(src, dst); } catch { /* file locked or already moved */ }
}
}
// Find channel code for broadcasting
const channel = db.prepare('SELECT code FROM channels WHERE id = ?').get(webhook.channel_id);
if (channel && io) {
io.to(`channel:${channel.code}`).emit('message-deleted', {
channelCode: channel.code,
messageId: mid
});
}
res.json({ success: true });
});
// ── Bot: Play a soundboard sound in the webhook's channel ──
app.post('/api/webhooks/:token/sounds', webhookLimiter, express.json({ limit: '16kb' }), (req, res) => {
const webhook = getWebhookByToken(req.params.token);
if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' });
const soundName = typeof req.body.sound === 'string' ? req.body.sound.trim() : '';
if (!soundName) return res.status(400).json({ error: 'sound name required' });
// Verify the sound exists
const { getDb } = require('./src/database');
const builtin = BUILTIN_SOUNDS.find(s => s.name === soundName);
let soundUrl;
if (builtin) {
soundUrl = builtin.url;
} else {
const custom = getDb().prepare('SELECT filename FROM custom_sounds WHERE name = ?').get(soundName);
if (!custom) return res.status(404).json({ error: 'Sound not found' });
soundUrl = `/uploads/${custom.filename}`;
}
// Find the channel code and broadcast the sound event
const channel = getDb().prepare('SELECT code FROM channels WHERE id = ?').get(webhook.channel_id);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (io) {
io.to(`channel:${channel.code}`).emit('play-sound', {
channelCode: channel.code,
soundUrl,
soundName,
botName: webhook.name
});
}
res.json({ success: true });
});
// ═══════════════════════════════════════════════════════════
// MODERATION REST API
// ═══════════════════════════════════════════════════════════
const modLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { error: 'Rate limit exceeded' } });
// Helper: get authenticated user from Bearer token with admin/mod check
function getModUser(req, permission) {
const token = req.headers.authorization?.split(' ')[1];
const user = token ? verifyToken(token) : null;
if (!user) return { error: 'Unauthorized', status: 401 };
if (!verifyAdminFromDb(user) && !userHasPermission(user.id, permission)) {
return { error: 'Insufficient permissions', status: 403 };
}
return { user };
}
// POST /api/moderation/kick
app.post('/api/moderation/kick', modLimiter, express.json({ limit: '16kb' }), (req, res) => {
const auth = getModUser(req, 'kick_user');
if (auth.error) return res.status(auth.status).json({ error: auth.error });
const { getDb } = require('./src/database');
const db = getDb();
const { userId, channelCode, reason } = req.body;
if (!userId || !Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' });
if (!channelCode || typeof channelCode !== 'string') return res.status(400).json({ error: 'channelCode required' });
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(channelCode);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
const target = db.prepare('SELECT id, COALESCE(display_name, username) as username FROM users WHERE id = ?').get(userId);
if (!target) return res.status(404).json({ error: 'User not found' });
db.prepare('DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?').run(channel.id, userId);
if (io) {
const safeReason = typeof reason === 'string' ? reason.trim().slice(0, 200) : '';
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === userId) {
s.emit('kicked', { channelCode, reason: safeReason });
s.leave(`channel:${channelCode}`);
}
}
}
res.json({ success: true, message: `Kicked ${target.username}` });
});
// POST /api/moderation/ban
app.post('/api/moderation/ban', modLimiter, express.json({ limit: '16kb' }), (req, res) => {
const auth = getModUser(req, 'ban_user');
if (auth.error) return res.status(auth.status).json({ error: auth.error });
const { getDb } = require('./src/database');
const db = getDb();
const { userId, reason } = req.body;
if (!userId || !Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' });
const target = db.prepare('SELECT id, COALESCE(display_name, username) as username, is_admin FROM users WHERE id = ?').get(userId);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_admin) return res.status(403).json({ error: 'Cannot ban an admin' });
const safeReason = typeof reason === 'string' ? reason.trim().slice(0, 200) : '';
try {
db.prepare('INSERT OR REPLACE INTO bans (user_id, banned_by, reason) VALUES (?, ?, ?)').run(userId, auth.user.id, safeReason);
} catch (err) {
return res.status(500).json({ error: 'Failed to ban user' });
}
if (io) {
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === userId) {
s.emit('banned', { reason: safeReason });
s.disconnect(true);
}
}
}
res.json({ success: true, message: `Banned ${target.username}` });
});
// POST /api/moderation/unban
app.post('/api/moderation/unban', modLimiter, express.json({ limit: '16kb' }), (req, res) => {
const auth = getModUser(req, 'ban_user');
if (auth.error) return res.status(auth.status).json({ error: auth.error });
const { getDb } = require('./src/database');
const db = getDb();
const { userId } = req.body;
if (!userId || !Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' });
db.prepare('DELETE FROM bans WHERE user_id = ?').run(userId);
const target = db.prepare('SELECT COALESCE(display_name, username) as username FROM users WHERE id = ?').get(userId);
res.json({ success: true, message: `Unbanned ${target ? target.username : 'user'}` });
});
// POST /api/moderation/mute
app.post('/api/moderation/mute', modLimiter, express.json({ limit: '16kb' }), (req, res) => {
const auth = getModUser(req, 'mute_user');
if (auth.error) return res.status(auth.status).json({ error: auth.error });
const { getDb } = require('./src/database');
const db = getDb();
const { userId, duration, reason } = req.body;
if (!userId || !Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' });
const target = db.prepare('SELECT id, COALESCE(display_name, username) as username FROM users WHERE id = ?').get(userId);
if (!target) return res.status(404).json({ error: 'User not found' });
const durationMs = Number.isInteger(duration) && duration > 0 ? duration * 60 * 1000 : 10 * 60 * 1000;
const expiresAt = new Date(Date.now() + durationMs).toISOString();
const safeReason = typeof reason === 'string' ? reason.trim().slice(0, 200) : '';
db.prepare('DELETE FROM mutes WHERE user_id = ?').run(userId);
db.prepare('INSERT INTO mutes (user_id, muted_by, reason, expires_at) VALUES (?, ?, ?, ?)').run(userId, auth.user.id, safeReason, expiresAt);
if (io) {
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === userId) {
s.emit('muted', { reason: safeReason, expiresAt });
}
}
}
res.json({ success: true, message: `Muted ${target.username} until ${expiresAt}` });
});
// POST /api/moderation/unmute
app.post('/api/moderation/unmute', modLimiter, express.json({ limit: '16kb' }), (req, res) => {
const auth = getModUser(req, 'mute_user');
if (auth.error) return res.status(auth.status).json({ error: auth.error });
const { getDb } = require('./src/database');
const db = getDb();
const { userId } = req.body;
if (!userId || !Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' });
db.prepare('DELETE FROM mutes WHERE user_id = ?').run(userId);
const target = db.prepare('SELECT COALESCE(display_name, username) as username FROM users WHERE id = ?').get(userId);
res.json({ success: true, message: `Unmuted ${target ? target.username : 'user'}` });
});
// GET /api/moderation/bans — list all bans
app.get('/api/moderation/bans', modLimiter, (req, res) => {
const auth = getModUser(req, 'ban_user');
if (auth.error) return res.status(auth.status).json({ error: auth.error });
const { getDb } = require('./src/database');
const bans = getDb().prepare(`
SELECT b.id, b.user_id, COALESCE(u.display_name, u.username) as username, b.reason, b.created_at
FROM bans b JOIN users u ON b.user_id = u.id ORDER BY b.created_at DESC
`).all();
res.json({ bans });
});
// GET /api/moderation/mutes — list active mutes
app.get('/api/moderation/mutes', modLimiter, (req, res) => {
const auth = getModUser(req, 'mute_user');
if (auth.error) return res.status(auth.status).json({ error: auth.error });
const { getDb } = require('./src/database');
const mutes = getDb().prepare(`
SELECT m.id, m.user_id, COALESCE(u.display_name, u.username) as username, m.reason, m.expires_at, m.created_at
FROM mutes m JOIN users u ON m.user_id = u.id WHERE m.expires_at > datetime('now') ORDER BY m.created_at DESC
`).all();
res.json({ mutes });
});
// ═══════════════════════════════════════════════════════════
// BOT SLASH COMMANDS API
// ═══════════════════════════════════════════════════════════
// Helper: authenticate webhook bot by token
function getWebhookByToken(token) {
if (!token || typeof token !== 'string' || token.length !== 64) return null;
const { getDb } = require('./src/database');
return getDb().prepare(
'SELECT id, name, channel_id, callback_url FROM webhooks WHERE token = ? AND is_active = 1'
).get(token);
}
// GET /api/webhooks/:token/commands — list registered commands
app.get('/api/webhooks/:token/commands', webhookLimiter, (req, res) => {
const webhook = getWebhookByToken(req.params.token);
if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' });
const { getDb } = require('./src/database');
const commands = getDb().prepare('SELECT id, command, description FROM bot_commands WHERE webhook_id = ?').all(webhook.id);
res.json({ commands });
});
// POST /api/webhooks/:token/commands — register a command
app.post('/api/webhooks/:token/commands', webhookLimiter, express.json({ limit: '16kb' }), (req, res) => {
const webhook = getWebhookByToken(req.params.token);
if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' });
if (!webhook.callback_url) return res.status(400).json({ error: 'Webhook must have a callback_url to register commands' });
const { command, description } = req.body;
if (!command || typeof command !== 'string') return res.status(400).json({ error: 'command required (string)' });
const cmd = command.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 32);
if (!cmd) return res.status(400).json({ error: 'Invalid command name' });
// Reject built-in command names
const builtIn = ['shrug','tableflip','unflip','lenny','disapprove','bbs','boobs','butt','brb','afk','me','spoiler','tts','flip','roll','hug','wave','play','gif','poll'];
if (builtIn.includes(cmd)) return res.status(409).json({ error: `/${cmd} is a built-in command` });
const desc = typeof description === 'string' ? description.trim().slice(0, 100) : '';
const { getDb } = require('./src/database');
try {
getDb().prepare('INSERT OR REPLACE INTO bot_commands (webhook_id, command, description) VALUES (?, ?, ?)').run(webhook.id, cmd, desc);
res.json({ success: true, command: cmd, description: desc });
} catch (err) {
res.status(500).json({ error: 'Failed to register command' });
}
});
// DELETE /api/webhooks/:token/commands/:command — unregister a command
app.delete('/api/webhooks/:token/commands/:command', webhookLimiter, (req, res) => {
const webhook = getWebhookByToken(req.params.token);
if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' });
const cmd = (req.params.command || '').toLowerCase().replace(/[^a-z0-9]/g, '');
if (!cmd) return res.status(400).json({ error: 'Invalid command name' });
const { getDb } = require('./src/database');
const result = getDb().prepare('DELETE FROM bot_commands WHERE webhook_id = ? AND command = ?').run(webhook.id, cmd);
if (result.changes === 0) return res.status(404).json({ error: 'Command not found' });
res.json({ success: true });
});
// GET /api/bot-commands — list all registered bot commands (for client autocomplete)
app.get('/api/bot-commands', (req, res) => {
const { getDb } = require('./src/database');
const commands = getDb().prepare(`
SELECT bc.command, bc.description, w.name as bot_name
FROM bot_commands bc
JOIN webhooks w ON bc.webhook_id = w.id
WHERE w.is_active = 1
`).all();
res.json({ commands });
});
// ═══════════════════════════════════════════════════════════
// DISCORD IMPORT — upload, preview, execute
// ═══════════════════════════════════════════════════════════
@ -2240,6 +2911,34 @@ const io = new Server(server, {
// Initialize
const db = initDatabase();
// ── Admin password reset (one-time, from .env) ───────────
// Set ADMIN_RESET_PASSWORD in .env, restart, and it resets the admin's password.
// The variable is removed from .env automatically after use.
if (process.env.ADMIN_RESET_PASSWORD) {
const bcryptSync = require('bcryptjs');
const adminName = (process.env.ADMIN_USERNAME || 'admin').toLowerCase();
const adminUser = db.prepare('SELECT id, username FROM users WHERE LOWER(username) = ?').get(adminName);
if (adminUser) {
const newHash = bcryptSync.hashSync(process.env.ADMIN_RESET_PASSWORD, 12);
const newPwv = (db.prepare('SELECT password_version FROM users WHERE id = ?').get(adminUser.id)?.password_version || 1) + 1;
db.prepare('UPDATE users SET password_hash = ?, password_version = ?, is_admin = 1 WHERE id = ?').run(newHash, newPwv, adminUser.id);
db.prepare('DELETE FROM bans WHERE user_id = ?').run(adminUser.id);
db.prepare('DELETE FROM mutes WHERE user_id = ?').run(adminUser.id);
console.log(`🔑 Admin password reset for "${adminUser.username}" via ADMIN_RESET_PASSWORD`);
// Remove the variable from .env so it doesn't re-run on next restart
try {
let envContent = fs.readFileSync(ENV_PATH, 'utf-8');
envContent = envContent.replace(/^ADMIN_RESET_PASSWORD=.*$/m, '').replace(/\n{3,}/g, '\n\n');
fs.writeFileSync(ENV_PATH, envContent);
console.log(' Removed ADMIN_RESET_PASSWORD from .env (one-time use)');
} catch {}
} else {
console.warn(`⚠️ ADMIN_RESET_PASSWORD set but no user "${adminName}" found — skipping`);
}
delete process.env.ADMIN_RESET_PASSWORD;
}
initFcm(DATA_DIR);
app.set('io', io); // expose to auth routes (session invalidation on password change)
setupSocketHandlers(io, db);
@ -2410,22 +3109,75 @@ const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0';
const protocol = useSSL ? 'https' : 'http';
// ── Crash log helper ─────────────────────────────────────
// Write crash events to a file so they survive even when stdout
// is not captured (common on systemd-less Pi setups, screen
// sessions that were closed, etc.).
const CRASH_LOG = path.join(DATA_DIR, 'crash.log');
function logCrash(label, detail) {
const ts = new Date().toISOString();
const mem = process.memoryUsage();
const line = `[${ts}] ${label}: ${detail instanceof Error ? detail.stack : detail}\n` +
` RSS=${Math.round(mem.rss / 1048576)}MB Heap=${Math.round(mem.heapUsed / 1048576)}/${Math.round(mem.heapTotal / 1048576)}MB\n`;
console.error(`⚠️ ${label}:`, detail);
try { fs.appendFileSync(CRASH_LOG, line); } catch { /* disk full / read-only */ }
}
// ── Global crash prevention ──────────────────────────────
// Prevent the entire server from dying due to an uncaught exception
// in a socket handler or background task. Log the error so it
// can be debugged, but keep the process alive.
process.on('uncaughtException', (err) => {
console.error('⚠️ Uncaught exception (server kept alive):', err);
logCrash('Uncaught exception (server kept alive)', err);
});
process.on('unhandledRejection', (reason) => {
console.error('⚠️ Unhandled promise rejection (server kept alive):', reason);
logCrash('Unhandled promise rejection (server kept alive)', reason);
});
// ── Process exit logging ─────────────────────────────────
// Catches ALL exits — including native crashes and V8 OOM.
// The 'exit' event fires even for abort() / SIGSEGV on some
// Node versions. We also log SIGABRT (V8 OOM fires this).
process.on('exit', (code) => {
if (code !== 0) {
const ts = new Date().toISOString();
const line = `[${ts}] Process exited with code ${code}\n`;
try { fs.appendFileSync(CRASH_LOG, line); } catch {}
}
});
// ── Event loop lag monitor ───────────────────────────────
// Detects when the event loop is blocked (heavy sync SQLite ops
// or native module work). Logs a warning when lag exceeds 500ms
// so we can correlate with crashes on low-power hardware.
let _lastTick = Date.now();
setInterval(() => {
const now = Date.now();
const lag = now - _lastTick - 2000; // expected interval is 2s
if (lag > 500) {
logCrash('Event loop lag', `${lag}ms (event loop was blocked)`);
}
_lastTick = now;
}, 2000).unref();
// ── Memory watchdog ──────────────────────────────────────
// Periodically log memory usage and nudge GC when heap is getting large.
// This helps prevent the Oilpan "large allocation" OOM in Haven Desktop
// where the server runs alongside Electron.
const MEM_WARN_MB = 350; // warn threshold
//
// Auto-detects system RAM so Raspberry Pi (1-4 GB) gets a lower
// threshold than a 32 GB desktop. Fallback: 350 MB.
const MEM_WARN_MB = (() => {
try {
const os = require('os');
const totalMB = Math.round(os.totalmem() / 1048576);
// Warn at ~40% of total RAM (aggressive for low-RAM devices)
const threshold = Math.round(totalMB * 0.4);
// Clamp between 150 MB (Pi Zero) and 500 MB (big box)
return Math.max(150, Math.min(500, threshold));
} catch { return 350; }
})();
setInterval(() => {
const mem = process.memoryUsage();
const heapMB = Math.round(mem.heapUsed / 1048576);
@ -2434,7 +3186,7 @@ setInterval(() => {
// Log if above warning threshold
if (rssMB > MEM_WARN_MB) {
console.warn(`⚠️ Memory high — RSS: ${rssMB} MB, Heap: ${heapMB} MB, External: ${extMB} MB`);
logCrash('Memory high', `RSS: ${rssMB} MB, Heap: ${heapMB} MB, External: ${extMB} MB (threshold: ${MEM_WARN_MB} MB)`);
// Nudge GC if --expose-gc was passed
if (global.gc) {
global.gc();
@ -2464,6 +3216,9 @@ server.listen(PORT, HOST, () => {
});
function gracefulShutdown(signal) {
const ts = new Date().toISOString();
const line = `[${ts}] Graceful shutdown: ${signal}\n`;
try { fs.appendFileSync(CRASH_LOG, line); } catch {}
console.log(`\n${signal} received — shutting down`);
io.close();
server.close(() => process.exit(0));

View file

@ -5,6 +5,8 @@ const crypto = require('crypto');
const { getDb } = require('./database');
const OTPAuth = require('otpauth');
const QRCode = require('qrcode');
const https = require('https');
const http = require('http');
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET;
@ -81,6 +83,86 @@ function sanitizeString(str, maxLen = 200) {
return str.trim().slice(0, maxLen);
}
// ── SSO Avatar Download ─────────────────────────────────
// Downloads a profile picture from a remote Haven server and saves it locally.
// Returns the local /uploads/ path, or throws on failure.
function downloadSSOAvatar(url) {
return new Promise((resolve, reject) => {
// Validate URL
let parsed;
try {
parsed = new URL(url);
} catch {
return reject(new Error('Invalid URL'));
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
return reject(new Error('Invalid protocol'));
}
const fetcher = parsed.protocol === 'https:' ? https : http;
const request = fetcher.get(url, { timeout: 10000 }, (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode}`));
}
const contentType = (res.headers['content-type'] || '').toLowerCase();
const validTypes = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' };
const ext = validTypes[contentType.split(';')[0].trim()];
if (!ext) {
res.resume();
return reject(new Error('Not a supported image type'));
}
// Limit to 2 MB
let size = 0;
const maxSize = 2 * 1024 * 1024;
const chunks = [];
res.on('data', (chunk) => {
size += chunk.length;
if (size > maxSize) {
res.destroy();
return reject(new Error('Image too large'));
}
chunks.push(chunk);
});
res.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
// Validate magic bytes
let validMagic = false;
if (ext === '.jpg') validMagic = buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF;
else if (ext === '.png') validMagic = buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47;
else if (ext === '.gif') validMagic = buffer.slice(0, 6).toString().startsWith('GIF8');
else if (ext === '.webp') validMagic = buffer.slice(0, 4).toString() === 'RIFF' && buffer.slice(8, 12).toString() === 'WEBP';
if (!validMagic) return reject(new Error('File content does not match image type'));
const filename = Date.now() + crypto.randomBytes(8).toString('hex') + ext;
const { UPLOADS_DIR } = require('./paths');
const path = require('path');
const fs = require('fs');
const filePath = path.join(UPLOADS_DIR, filename);
fs.writeFileSync(filePath, buffer);
resolve(`/uploads/${filename}`);
} catch (err) {
reject(err);
}
});
res.on('error', reject);
});
request.on('error', reject);
request.on('timeout', () => {
request.destroy();
reject(new Error('Download timed out'));
});
});
}
// ── Register ──────────────────────────────────────────────
router.post('/register', async (req, res) => {
try {
@ -131,9 +213,21 @@ router.post('/register', async (req, res) => {
const hash = await bcrypt.hash(password, 12);
const isAdmin = username.toLowerCase() === ADMIN_USERNAME ? 1 : 0;
// SSO profile picture: download from home server if provided
const ssoProfilePicture = typeof req.body.ssoProfilePicture === 'string' ? req.body.ssoProfilePicture.trim().slice(0, 500) : null;
let avatarPath = null;
if (ssoProfilePicture) {
try {
avatarPath = await downloadSSOAvatar(ssoProfilePicture);
} catch (err) {
console.warn('[SSO] Avatar download failed:', err.message);
// Non-fatal — proceed without avatar
}
}
const result = db.prepare(
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)'
).run(username, hash, isAdmin);
'INSERT INTO users (username, password_hash, is_admin, avatar) VALUES (?, ?, ?, ?)'
).run(username, hash, isAdmin, avatarPath);
// Auto-assign roles flagged as auto_assign to new users
try {
@ -257,6 +351,23 @@ router.post('/login', async (req, res) => {
}
});
// ── Validate token (lightweight, for SSO consent page) ───
router.get('/validate', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const decoded = token ? verifyToken(token) : null;
if (!decoded) return res.status(401).json({ error: 'Invalid token' });
const db = getDb();
const user = db.prepare('SELECT username, display_name, avatar FROM users WHERE id = ?').get(decoded.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({
username: user.username,
displayName: user.display_name || user.username,
avatar: user.avatar || null
});
});
// ── TOTP Validate (second step of login) ─────────────────
router.post('/totp/validate', async (req, res) => {
try {
@ -811,4 +922,394 @@ function generateChannelCode() {
return crypto.randomBytes(4).toString('hex'); // 8-char hex string
}
// ── Encrypted Server List (cross-device sync) ───────────
// Client encrypts/decrypts the server list with the user's password-derived key.
// Server stores only the opaque blob — no visibility into URLs or network graph.
router.get('/user-servers', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const decoded = token ? verifyToken(token) : null;
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
try {
const db = getDb();
const row = db.prepare('SELECT encrypted_servers FROM users WHERE id = ?').get(decoded.id);
res.json({ blob: row?.encrypted_servers || null });
} catch (err) {
console.error('Get user-servers error:', err);
res.status(500).json({ error: 'Server error' });
}
});
router.put('/user-servers', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const decoded = token ? verifyToken(token) : null;
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
const blob = typeof req.body.blob === 'string' ? req.body.blob : null;
if (blob && blob.length > 65536) {
return res.status(400).json({ error: 'Server list too large' });
}
try {
const db = getDb();
db.prepare('UPDATE users SET encrypted_servers = ? WHERE id = ?').run(blob, decoded.id);
res.json({ ok: true });
} catch (err) {
console.error('Put user-servers error:', err);
res.status(500).json({ error: 'Server error' });
}
});
// ── SSO (Sign in with existing Haven server) ───────────
// Allows other Haven servers to pre-fill registration with this user's profile.
// Flow: foreign server opens /SSO?authCode=X → user confirms → foreign server
// calls /SSO/authenticate?authCode=X to retrieve public profile data.
const pendingSSO = new Map();
// Rate limiter for SSO authenticate endpoint (prevents auth code brute-force)
const ssoRateLimitStore = new Map();
function ssoAuthLimiter(req, res, next) {
const ip = req.ip || req.socket.remoteAddress;
const now = Date.now();
const windowMs = 60 * 1000; // 1 minute
const maxAttempts = 5;
if (!ssoRateLimitStore.has(ip)) ssoRateLimitStore.set(ip, []);
const timestamps = ssoRateLimitStore.get(ip).filter(t => now - t < windowMs);
ssoRateLimitStore.set(ip, timestamps);
if (timestamps.length >= maxAttempts) {
return res.status(429).json({ error: 'Too many attempts. Try again in a minute.' });
}
timestamps.push(now);
next();
}
// Clean up SSO rate limit entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [ip, timestamps] of ssoRateLimitStore) {
const fresh = timestamps.filter(t => now - t < 60000);
if (fresh.length === 0) ssoRateLimitStore.delete(ip);
else ssoRateLimitStore.set(ip, fresh);
}
}, 5 * 60 * 1000);
// GET /api/auth/SSO?authCode=X — Consent/authorize page
// The user must be logged in (valid JWT in localStorage). The page is client-rendered
// and reads the token from localStorage to make the approve call.
router.get('/SSO', (req, res) => {
const authCode = typeof req.query.authCode === 'string' ? req.query.authCode.trim() : '';
const origin = typeof req.query.origin === 'string' ? req.query.origin.trim().slice(0, 200) : '';
if (!authCode || authCode.length < 32 || authCode.length > 128) {
return res.status(400).send('Invalid or missing auth code.');
}
const safeAuthCode = authCode.replace(/[^a-fA-F0-9]/g, '');
const safeOrigin = origin.replace(/[<>"'&]/g, '');
// Serve a self-contained consent page that reads JWT from localStorage
res.send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Haven SSO</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d0d1a; color: #e0e0e0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.card { background: #1a1a2e; border: 1px solid #333; border-radius: 12px; padding: 32px; max-width: 400px; width: 90%; text-align: center; }
.card h2 { margin-bottom: 8px; font-size: 20px; }
.card p { color: #aaa; font-size: 14px; margin-bottom: 20px; }
.origin { color: #6b4fdb; font-weight: 600; word-break: break-all; }
.info { background: #12122a; border: 1px solid #2a2a4a; border-radius: 8px; padding: 12px; margin-bottom: 20px; text-align: left; font-size: 13px; }
.info-row { display: flex; justify-content: space-between; padding: 4px 0; }
.info-label { color: #888; }
.info-value { color: #e0e0e0; font-weight: 500; }
.btn { display: inline-block; padding: 10px 24px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; margin: 4px; transition: opacity 0.2s; }
.btn-primary { background: #6b4fdb; color: #fff; }
.btn-primary:hover { opacity: 0.9; }
.btn-cancel { background: transparent; color: #888; border: 1px solid #444; }
.btn-cancel:hover { color: #fff; border-color: #666; }
.success { display: none; color: #4ade80; font-size: 15px; margin-top: 12px; }
.not-logged-in { color: #ef4444; }
.loading { color: #888; }
.debug { margin-top: 10px; font-size: 12px; color: #8f95b2; word-break: break-word; }
.debug.error { color: #ef4444; }
.debug.ok { color: #4ade80; }
</style>
</head>
<body>
<div class="card">
<h2> Haven SSO</h2>
<div id="loading" class="loading"><p>Checking login status...</p></div>
<div id="sso-debug" class="debug">Starting SSO checks...</div>
<div id="not-logged-in" style="display:none">
<p class="not-logged-in">You are not logged in to this server.</p>
<p style="font-size:13px;color:#888;margin-top:8px">Log in first, then try again.</p>
<button class="btn btn-primary" onclick="window.location.href='/'">Go to Login</button>
</div>
<div id="consent" style="display:none">
<p>Another Haven server wants to use your identity to pre-fill registration.</p>
${safeOrigin ? `<p>Requesting server: <span class="origin">${safeOrigin}</span></p>` : ''}
<div class="info">
<div class="info-row"><span class="info-label">Username</span><span class="info-value" id="sso-username"></span></div>
<div class="info-row"><span class="info-label">Profile picture</span><span class="info-value" id="sso-avatar"></span></div>
</div>
<p style="font-size:12px;color:#666">Your password is <strong>never</strong> shared. Only your username and profile picture.</p>
<div id="buttons">
<button class="btn btn-primary" id="approve-btn">Approve</button>
<button class="btn btn-cancel" onclick="window.close()">Cancel</button>
</div>
<p class="success" id="success-msg"> Approved! You can close this tab.</p>
</div>
</div>
<script>
const authCode = '${safeAuthCode}';
const origin = '${safeOrigin}';
let approvedProfile = null;
(async function() {
const loadingEl = document.getElementById('loading');
const debugEl = document.getElementById('sso-debug');
function setDebug(msg, tone = '') {
if (!debugEl) return;
debugEl.textContent = msg;
debugEl.className = 'debug' + (tone ? (' ' + tone) : '');
}
function showNotLoggedIn(reason = 'No active login was found on this server.') {
document.getElementById('loading').style.display = 'none';
document.getElementById('not-logged-in').style.display = 'block';
setDebug(reason, 'error');
}
function showConsentReady() {
document.getElementById('loading').style.display = 'none';
document.getElementById('consent').style.display = 'block';
setDebug('Login verified. You can approve this SSO request.', 'ok');
}
// Safety watchdog: if anything stalls, stop showing an indefinite spinner.
const bootTimeout = setTimeout(() => {
if (loadingEl && loadingEl.style.display !== 'none') {
// If we have a cached user profile, use that instead of failing — server
// may simply be slow/unreachable for the validate endpoint, but the
// profile we'll share is already cached locally.
try {
const cachedRaw = localStorage.getItem('haven_user');
const cached = cachedRaw ? JSON.parse(cachedRaw) : null;
if (cached && cached.username) {
approvedProfile = {
username: cached.username,
displayName: cached.displayName || cached.username,
profilePicture: cached.avatar || null
};
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
document.getElementById('sso-avatar').textContent = cached.avatar ? 'Will be shared' : 'None set';
showConsentReady();
setDebug('Using cached profile (validate endpoint did not respond in time).', 'ok');
return;
}
} catch {}
showNotLoggedIn('SSO check timed out. Try refreshing this page or logging in again.');
}
}, 5000);
let token;
try {
setDebug('Reading local login token...');
token = localStorage.getItem('haven_token');
} catch {
// localStorage blocked (third-party cookies, popup restrictions, etc.)
showNotLoggedIn('Browser storage is blocked in this tab, so Haven cannot read your login token.');
clearTimeout(bootTimeout);
return;
}
if (!token) {
showNotLoggedIn('No Haven login token found in this browser profile.');
clearTimeout(bootTimeout);
return;
}
// Verify token is still valid by calling the server
try {
setDebug('Validating token with this server...');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 4000);
const verifyRes = await fetch('/api/auth/validate', {
headers: { 'Authorization': 'Bearer ' + token },
signal: ctrl.signal
});
clearTimeout(timer);
if (!verifyRes.ok) {
showNotLoggedIn('Token validation failed (' + verifyRes.status + '). Please log in again.');
clearTimeout(bootTimeout);
return;
}
const userData = await verifyRes.json();
approvedProfile = {
username: userData.username || '—',
displayName: userData.displayName || userData.username || '—',
profilePicture: userData.avatar || null
};
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
document.getElementById('sso-avatar').textContent = userData.avatar ? 'Will be shared' : 'None set';
showConsentReady();
clearTimeout(bootTimeout);
} catch {
// Fall back to localStorage user data if validate endpoint unavailable
try {
setDebug('Validate endpoint unavailable. Falling back to local profile data...');
const userStr = localStorage.getItem('haven_user');
const user = userStr ? JSON.parse(userStr) : null;
if (!user) {
showNotLoggedIn('Could not validate token and no cached local user profile was found.');
clearTimeout(bootTimeout);
return;
}
approvedProfile = {
username: user.username || '—',
displayName: user.displayName || user.username || '—',
profilePicture: user.avatar || null
};
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
document.getElementById('sso-avatar').textContent = user.avatar ? 'Will be shared' : 'None set';
showConsentReady();
clearTimeout(bootTimeout);
} catch {
showNotLoggedIn('Failed to read cached local profile for SSO consent.');
clearTimeout(bootTimeout);
return;
}
}
document.getElementById('approve-btn').addEventListener('click', async () => {
const btn = document.getElementById('approve-btn');
btn.disabled = true;
btn.textContent = 'Approving...';
try {
const res = await fetch('/api/auth/SSO/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ authCode, origin })
});
if (res.ok) {
setDebug('Approval stored on home server. Returning profile to requesting server...', 'ok');
if (origin && window.opener && approvedProfile) {
try {
window.opener.postMessage({
type: 'haven-sso-approved',
authCode,
profile: approvedProfile,
serverOrigin: window.location.origin
}, origin);
} catch {}
}
document.getElementById('buttons').style.display = 'none';
document.getElementById('success-msg').style.display = 'block';
} else {
const data = await res.json().catch(() => ({}));
setDebug(data.error || 'Approval failed on home server.', 'error');
alert(data.error || 'Failed to approve');
btn.disabled = false;
btn.textContent = 'Approve';
}
} catch {
setDebug('Connection error while approving SSO request.', 'error');
alert('Connection error');
btn.disabled = false;
btn.textContent = 'Approve';
}
});
})();
</script>
</body>
</html>`);
});
// POST /api/auth/SSO/approve — User clicks Approve on consent page
router.post('/SSO/approve', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const decoded = token ? verifyToken(token) : null;
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
const authCode = typeof req.body.authCode === 'string' ? req.body.authCode.trim() : '';
const origin = typeof req.body.origin === 'string' ? req.body.origin.trim().slice(0, 200) : '';
if (!authCode || authCode.length < 32) {
return res.status(400).json({ error: 'Invalid auth code' });
}
// Prevent duplicate approvals
if (pendingSSO.has(authCode)) {
return res.status(400).json({ error: 'Auth code already used' });
}
pendingSSO.set(authCode, { userId: decoded.id, origin, approvedAt: Date.now() });
// Auto-expire after 60 seconds
setTimeout(() => pendingSSO.delete(authCode), 60000);
res.json({ ok: true });
});
// GET /api/auth/SSO/authenticate?authCode=X — Foreign server calls this to retrieve user info
// This is called by the CLIENT on the foreign server, not server-to-server.
router.get('/SSO/authenticate', ssoAuthLimiter, (req, res) => {
const requestOrigin = req.headers.origin;
if (requestOrigin) {
res.set('Access-Control-Allow-Origin', requestOrigin);
res.set('Vary', 'Origin');
res.set('Access-Control-Allow-Credentials', 'false');
}
const authCode = typeof req.query.authCode === 'string' ? req.query.authCode.trim() : '';
if (!authCode) return res.status(400).json({ error: 'Missing auth code' });
const pending = pendingSSO.get(authCode);
if (!pending) return res.status(404).json({ error: 'Invalid or expired auth code' });
// One-time use: delete immediately
pendingSSO.delete(authCode);
// If this auth code was issued for a specific origin, mirror it for strictness.
if (pending.origin) res.set('Access-Control-Allow-Origin', pending.origin);
const db = getDb();
const user = db.prepare('SELECT username, avatar, display_name FROM users WHERE id = ?').get(pending.userId);
if (!user) return res.status(404).json({ error: 'User not found' });
// Build the avatar URL — if it's a relative path, make it absolute
let avatarUrl = user.avatar || null;
if (avatarUrl && avatarUrl.startsWith('/')) {
// The client will need to construct the full URL using the home server address
// We return it as-is (relative) and the client prepends the server URL
}
res.json({
username: user.username,
displayName: user.display_name || user.username,
profilePicture: avatarUrl
});
});
// CORS preflight for SSO/authenticate (cross-origin requests from foreign Haven clients)
router.options('/SSO/authenticate', (req, res) => {
const origin = req.headers.origin;
if (origin) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
res.set('Access-Control-Max-Age', '600');
}
res.sendStatus(204);
});
module.exports = { router, verifyToken, generateChannelCode, generateToken, authLimiter };

View file

@ -210,6 +210,10 @@ function initDatabase() {
insertSetting.run('setup_wizard_complete', 'false'); // first-time admin setup wizard
insertSetting.run('update_banner_admin_only', 'false'); // hide update banner from non-admins
// Unique server fingerprint — used by the multi-server sidebar to detect "self"
const crypto = require('crypto');
insertSetting.run('server_fingerprint', crypto.randomUUID());
// ── Migration: pinned_messages table ──────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS pinned_messages (
@ -759,6 +763,20 @@ function initDatabase() {
db.exec("ALTER TABLE channels ADD COLUMN afk_timeout_minutes INTEGER DEFAULT 0");
}
// ── Migration: read-only channel column ─────────────────
try {
db.prepare("SELECT read_only FROM channels LIMIT 0").get();
} catch {
db.exec("ALTER TABLE channels ADD COLUMN read_only INTEGER DEFAULT 0");
}
// ── Migration: encrypted server list for cross-device sync ──────────
try {
db.prepare("SELECT encrypted_servers FROM users LIMIT 0").get();
} catch {
db.exec("ALTER TABLE users ADD COLUMN encrypted_servers TEXT DEFAULT NULL");
}
// ── Migration: grant use_tts to all auto-assign roles (default ON) ──
try {
const autoAssignRoles = db.prepare('SELECT id FROM roles WHERE auto_assign = 1').all();
@ -768,6 +786,35 @@ function initDatabase() {
}
} catch { /* non-critical */ }
// ── Migration: role icon column ─────────────────────────
try {
db.prepare("SELECT icon FROM roles LIMIT 0").get();
} catch {
db.exec("ALTER TABLE roles ADD COLUMN icon TEXT DEFAULT NULL");
}
// ── Migration: bot_commands table for extensible slash commands ──
db.exec(`
CREATE TABLE IF NOT EXISTS bot_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id INTEGER NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
command TEXT NOT NULL,
description TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(webhook_id, command)
);
CREATE INDEX IF NOT EXISTS idx_bot_commands_command ON bot_commands(command);
CREATE INDEX IF NOT EXISTS idx_bot_commands_webhook ON bot_commands(webhook_id);
`);
// ── Migration: chat threads (thread_id on messages) ─────
try {
db.prepare("SELECT thread_id FROM messages LIMIT 0").get();
} catch {
db.exec("ALTER TABLE messages ADD COLUMN thread_id INTEGER DEFAULT NULL REFERENCES messages(id) ON DELETE CASCADE");
}
db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id) WHERE thread_id IS NOT NULL");
return db;
}

File diff suppressed because it is too large Load diff

495
src/socketHandlers/admin.js Normal file
View file

@ -0,0 +1,495 @@
'use strict';
const crypto = require('crypto');
const { utcStamp, isInt, isValidUploadPath, VALID_ROLE_PERMS } = require('./helpers');
module.exports = function register(socket, ctx) {
const {
io, db, state, userHasPermission, getUserEffectiveLevel,
getUserPermissions, getUserRoles, getUserHighestRole,
emitOnlineUsers, broadcastChannelLists, generateChannelCode
} = ctx;
const { channelUsers } = state;
// ── Server settings ─────────────────────────────────────
socket.on('get-server-settings', () => {
const rows = db.prepare('SELECT key, value FROM server_settings').all();
const settings = {};
const sensitiveKeys = ['giphy_api_key', 'server_code'];
rows.forEach(r => {
if (sensitiveKeys.includes(r.key) && !socket.user.isAdmin) return;
settings[r.key] = r.value;
});
socket.emit('server-settings', settings);
});
socket.on('update-server-setting', (data) => {
if (!data || typeof data !== 'object') return;
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_server')) {
return socket.emit('error-msg', 'Only admins can change server settings');
}
const key = typeof data.key === 'string' ? data.key.trim() : '';
const value = typeof data.value === 'string' ? data.value.trim() : '';
const allowedKeys = [
'member_visibility', 'cleanup_enabled', 'cleanup_max_age_days', 'cleanup_max_size_mb',
'giphy_api_key', 'server_name', 'server_title', 'server_icon', 'server_banner', 'permission_thresholds',
'tunnel_enabled', 'tunnel_provider', 'server_code', 'max_upload_mb', 'max_poll_options',
'max_sound_kb', 'max_emoji_kb', 'setup_wizard_complete', 'update_banner_admin_only',
'default_theme', 'channel_sort_mode', 'channel_cat_order', 'channel_cat_sort',
'channel_tag_sorts', 'custom_tos', 'welcome_message', 'vanity_code',
'role_icon_sidebar', 'role_icon_chat', 'role_icon_after_name'
];
if (!allowedKeys.includes(key)) return;
if (key === 'member_visibility' && !['all', 'online', 'none'].includes(value)) return;
if (key === 'cleanup_enabled' && !['true', 'false'].includes(value)) return;
if (key === 'cleanup_max_age_days') { const n = parseInt(value); if (isNaN(n) || n < 0 || n > 3650) return; }
if (key === 'cleanup_max_size_mb') { const n = parseInt(value); if (isNaN(n) || n < 0 || n > 100000) return; }
if (key === 'max_upload_mb') { const n = parseInt(value); if (isNaN(n) || n < 1 || n > 2048) return; }
if (key === 'max_poll_options') { const n = parseInt(value); if (isNaN(n) || n < 2 || n > 25) return; }
if (key === 'max_sound_kb') { const n = parseInt(value); if (isNaN(n) || n < 256 || n > 10240) return; }
if (key === 'max_emoji_kb') { const n = parseInt(value); if (isNaN(n) || n < 64 || n > 1024) return; }
if (key === 'giphy_api_key') { if (value && (value.length < 10 || value.length > 100)) return; }
if (key === 'server_name') { if (value.length > 32) return; }
if (key === 'server_title') { if (value.length > 40) return; }
if (key === 'server_icon') { if (value && !isValidUploadPath(value)) return; }
if (key === 'tunnel_enabled' && !['true', 'false'].includes(value)) return;
if (key === 'tunnel_provider' && !['localtunnel', 'cloudflared'].includes(value)) return;
if (key === 'setup_wizard_complete' && !['true', 'false'].includes(value)) return;
if (key === 'update_banner_admin_only' && !['true', 'false'].includes(value)) return;
if (key === 'role_icon_sidebar' && !['true', 'false'].includes(value)) return;
if (key === 'role_icon_chat' && !['true', 'false'].includes(value)) return;
if (key === 'role_icon_after_name' && !['true', 'false'].includes(value)) return;
if (key === 'channel_sort_mode' && !['manual', 'alpha', 'created', 'oldest', 'dynamic'].includes(value)) return;
if (key === 'channel_cat_sort' && !['az', 'za', 'manual'].includes(value)) return;
if (key === 'channel_cat_order') {
try { const arr = JSON.parse(value); if (!Array.isArray(arr)) return; } catch { return; }
}
if (key === 'channel_tag_sorts') {
try {
const obj = JSON.parse(value);
if (typeof obj !== 'object' || Array.isArray(obj)) return;
const validModes = ['manual', 'alpha', 'created', 'oldest', 'dynamic'];
for (const v of Object.values(obj)) { if (!validModes.includes(v)) return; }
} catch { return; }
}
if (key === 'default_theme') {
const validThemes = ['', 'haven', 'discord', 'matrix', 'fallout', 'ffx', 'ice', 'nord', 'darksouls', 'eldenring', 'bloodborne', 'cyberpunk', 'lotr', 'abyss', 'scripture', 'chapel', 'gospel', 'tron', 'halo', 'dracula', 'win95'];
if (!validThemes.includes(value)) return;
}
if (key === 'custom_tos') { if (value.length > 50000) return; }
if (key === 'welcome_message') { if (value.length > 500) return; }
if (key === 'server_code') return; // managed via generate/rotate events
if (key === 'server_banner') { if (value && !isValidUploadPath(value)) return; }
if (key === 'vanity_code') {
if (value && (value.length < 3 || value.length > 32 || !/^[a-zA-Z0-9_-]+$/.test(value))) return;
}
if (key === 'permission_thresholds') {
try {
const obj = JSON.parse(value);
if (typeof obj !== 'object' || Array.isArray(obj)) return;
for (const [k, v] of Object.entries(obj)) {
if (!VALID_ROLE_PERMS.includes(k)) return;
if (!Number.isInteger(v) || v < 1 || v > 100) return;
}
} catch { return; }
}
try {
db.prepare('INSERT OR REPLACE INTO server_settings (key, value) VALUES (?, ?)').run(key, value);
} catch (err) {
console.error('Failed to save server setting:', key, err.message);
return socket.emit('error-msg', 'Failed to save setting — database write error');
}
io.emit('server-setting-changed', { key, value });
if (key === 'member_visibility') {
for (const [code] of channelUsers) { emitOnlineUsers(code); }
}
});
// ── Whitelist management ────────────────────────────────
socket.on('get-whitelist', () => {
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_server')) return;
const rows = db.prepare('SELECT id, username, created_at FROM whitelist ORDER BY username').all();
rows.forEach(r => { r.created_at = utcStamp(r.created_at); });
socket.emit('whitelist-list', rows);
});
socket.on('whitelist-add', (data) => {
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_server')) return;
if (!data || typeof data !== 'object') return;
const username = typeof data.username === 'string' ? data.username.trim() : '';
if (!username || username.length < 3 || username.length > 20) {
return socket.emit('error-msg', 'Username must be 3-20 characters');
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return socket.emit('error-msg', 'Invalid username format');
}
try {
db.prepare('INSERT OR IGNORE INTO whitelist (username, added_by) VALUES (?, ?)').run(username, socket.user.id);
socket.emit('error-msg', `Added "${username}" to whitelist`);
const rows = db.prepare('SELECT id, username, created_at FROM whitelist ORDER BY username').all();
rows.forEach(r => { r.created_at = utcStamp(r.created_at); });
socket.emit('whitelist-list', rows);
} catch {
socket.emit('error-msg', 'Failed to add to whitelist');
}
});
socket.on('whitelist-remove', (data) => {
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_server')) return;
if (!data || typeof data !== 'object') return;
const username = typeof data.username === 'string' ? data.username.trim() : '';
if (!username) return;
db.prepare('DELETE FROM whitelist WHERE username = ?').run(username);
socket.emit('error-msg', `Removed "${username}" from whitelist`);
const rows = db.prepare('SELECT id, username, created_at FROM whitelist ORDER BY username').all();
rows.forEach(r => { r.created_at = utcStamp(r.created_at); });
socket.emit('whitelist-list', rows);
});
socket.on('whitelist-toggle', (data) => {
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_server')) return;
if (!data || typeof data !== 'object') return;
const enabled = data.enabled === true ? 'true' : 'false';
db.prepare("INSERT OR REPLACE INTO server_settings (key, value) VALUES ('whitelist_enabled', ?)").run(enabled);
socket.emit('error-msg', `Whitelist ${enabled === 'true' ? 'enabled' : 'disabled'}`);
});
// ── Server invite code ──────────────────────────────────
socket.on('generate-server-code', () => {
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_server')) {
return socket.emit('error-msg', 'Only admins can manage server codes');
}
const code = generateChannelCode();
db.prepare('INSERT OR REPLACE INTO server_settings (key, value) VALUES (?, ?)').run('server_code', code);
io.emit('server-setting-changed', { key: 'server_code', value: code });
socket.emit('error-msg', `Server invite code generated: ${code}`);
});
socket.on('clear-server-code', () => {
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_server')) {
return socket.emit('error-msg', 'Only admins can manage server codes');
}
db.prepare('INSERT OR REPLACE INTO server_settings (key, value) VALUES (?, ?)').run('server_code', '');
io.emit('server-setting-changed', { key: 'server_code', value: '' });
socket.emit('error-msg', 'Server invite code cleared');
});
// ── Run cleanup ─────────────────────────────────────────
socket.on('run-cleanup-now', () => {
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_server')) {
return socket.emit('error-msg', 'Only admins can run cleanup');
}
if (typeof global.runAutoCleanup === 'function') {
global.runAutoCleanup();
socket.emit('error-msg', 'Cleanup ran — check server console for details');
} else {
socket.emit('error-msg', 'Cleanup function not available');
}
});
// ── Webhooks / Bot integrations (consolidated) ──────────
// Two calling conventions:
// Bot-manager modal: uses data.channel_id (integer), data.id for delete/toggle
// Per-channel modal: uses data.channelCode (string), data.webhookId for delete/toggle
socket.on('create-webhook', (data) => {
if (!data || typeof data !== 'object') return;
if (!socket.user.isAdmin) return socket.emit('error-msg', 'Only admins can create webhooks');
if (data.channelCode) {
// Per-channel variant
const channelCode = typeof data.channelCode === 'string' ? data.channelCode.trim() : '';
if (!channelCode || !/^[a-f0-9]{8}$/i.test(channelCode)) return;
const channel = db.prepare('SELECT id, code FROM channels WHERE code = ? AND is_dm = 0').get(channelCode);
if (!channel) return socket.emit('error-msg', 'Channel not found');
const name = typeof data.name === 'string' ? data.name.trim().slice(0, 32) : 'Bot';
if (!name) return socket.emit('error-msg', 'Webhook name is required');
const token = crypto.randomBytes(32).toString('hex');
try {
const result = db.prepare(
'INSERT INTO webhooks (channel_id, name, token, created_by) VALUES (?, ?, ?, ?)'
).run(channel.id, name, token, socket.user.id);
socket.emit('webhook-created', {
id: result.lastInsertRowid, channel_id: channel.id,
channel_code: channel.code, name, token, is_active: 1,
created_at: new Date().toISOString()
});
} catch (err) {
console.error('Create webhook error:', err);
socket.emit('error-msg', 'Failed to create webhook');
}
} else {
// Bot-manager variant
const name = typeof data.name === 'string' ? data.name.trim().slice(0, 32) : '';
const channelId = parseInt(data.channel_id);
const avatarUrl = typeof data.avatar_url === 'string' ? data.avatar_url.trim().slice(0, 512) : null;
if (!name || isNaN(channelId)) return socket.emit('error-msg', 'Name and channel required');
const channel = db.prepare('SELECT id, name FROM channels WHERE id = ?').get(channelId);
if (!channel) return socket.emit('error-msg', 'Channel not found');
const token = crypto.randomBytes(32).toString('hex');
db.prepare(
'INSERT INTO webhooks (channel_id, name, token, avatar_url, created_by) VALUES (?, ?, ?, ?, ?)'
).run(channelId, name, token, avatarUrl, socket.user.id);
const webhooks = db.prepare(`
SELECT w.id, w.channel_id, w.name, w.token, w.avatar_url, w.is_active, w.created_at,
w.callback_url, w.callback_secret,
c.name as channel_name, c.code as channel_code
FROM webhooks w JOIN channels c ON w.channel_id = c.id
ORDER BY w.created_at DESC
`).all();
socket.emit('webhooks-list', { webhooks });
socket.emit('error-msg', `Webhook "${name}" created for #${channel.name}`);
}
});
socket.on('get-webhooks', (data) => {
if (!socket.user.isAdmin) return;
if (data && typeof data === 'object' && data.channelCode) {
// Per-channel variant
const channelCode = typeof data.channelCode === 'string' ? data.channelCode.trim() : '';
if (!channelCode || !/^[a-f0-9]{8}$/i.test(channelCode)) return;
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(channelCode);
if (!channel) return;
const webhooks = db.prepare(
'SELECT id, channel_id, name, token, avatar_url, is_active, created_at, callback_url, callback_secret FROM webhooks WHERE channel_id = ? ORDER BY created_at DESC'
).all(channel.id);
socket.emit('webhooks-list', { channelCode, webhooks });
} else {
// Bot-manager variant (all webhooks)
const webhooks = db.prepare(`
SELECT w.id, w.channel_id, w.name, w.token, w.avatar_url, w.is_active, w.created_at,
w.callback_url, w.callback_secret,
c.name as channel_name, c.code as channel_code
FROM webhooks w JOIN channels c ON w.channel_id = c.id
ORDER BY w.created_at DESC
`).all();
socket.emit('webhooks-list', { webhooks });
}
});
socket.on('delete-webhook', (data) => {
if (!data || typeof data !== 'object') return;
if (!socket.user.isAdmin) return socket.emit('error-msg', 'Only admins can manage webhooks');
// Per-channel variant uses webhookId, bot-manager uses id
const webhookId = parseInt(data.webhookId || data.id);
if (!webhookId || isNaN(webhookId)) return;
db.prepare('DELETE FROM webhooks WHERE id = ?').run(webhookId);
if (data.webhookId) {
// Per-channel response
socket.emit('webhook-deleted', { webhookId });
} else {
// Bot-manager response — return full list
const webhooks = db.prepare(`
SELECT w.id, w.channel_id, w.name, w.token, w.avatar_url, w.is_active, w.created_at,
w.callback_url, w.callback_secret,
c.name as channel_name, c.code as channel_code
FROM webhooks w JOIN channels c ON w.channel_id = c.id
ORDER BY w.created_at DESC
`).all();
socket.emit('webhooks-list', { webhooks });
socket.emit('error-msg', 'Webhook deleted');
}
});
socket.on('toggle-webhook', (data) => {
if (!data || typeof data !== 'object') return;
if (!socket.user.isAdmin) return socket.emit('error-msg', 'Only admins can manage webhooks');
const webhookId = parseInt(data.webhookId || data.id);
if (!webhookId || isNaN(webhookId)) return;
const wh = db.prepare('SELECT is_active FROM webhooks WHERE id = ?').get(webhookId);
if (!wh) return socket.emit('error-msg', 'Webhook not found');
const newState = wh.is_active ? 0 : 1;
db.prepare('UPDATE webhooks SET is_active = ? WHERE id = ?').run(newState, webhookId);
if (data.webhookId) {
// Per-channel response
socket.emit('webhook-toggled', { webhookId, is_active: newState });
} else {
// Bot-manager response — return full list
const webhooks = db.prepare(`
SELECT w.id, w.channel_id, w.name, w.token, w.avatar_url, w.is_active, w.created_at,
w.callback_url, w.callback_secret,
c.name as channel_name, c.code as channel_code
FROM webhooks w JOIN channels c ON w.channel_id = c.id
ORDER BY w.created_at DESC
`).all();
socket.emit('webhooks-list', { webhooks });
}
});
socket.on('update-webhook', (data) => {
if (!socket.user.isAdmin) return socket.emit('error-msg', 'Only admins can manage webhooks');
if (!data || typeof data !== 'object') return;
const webhookId = parseInt(data.id);
if (isNaN(webhookId)) return;
const wh = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(webhookId);
if (!wh) return socket.emit('error-msg', 'Webhook not found');
if (typeof data.name === 'string' && data.name.trim()) {
db.prepare('UPDATE webhooks SET name = ? WHERE id = ?').run(data.name.trim().slice(0, 32), webhookId);
}
if (data.channel_id !== undefined) {
const channelId = parseInt(data.channel_id);
if (!isNaN(channelId)) {
const channel = db.prepare('SELECT id FROM channels WHERE id = ?').get(channelId);
if (channel) db.prepare('UPDATE webhooks SET channel_id = ? WHERE id = ?').run(channelId, webhookId);
}
}
if (data.avatar_url !== undefined) {
const av = typeof data.avatar_url === 'string' ? data.avatar_url.trim().slice(0, 512) : null;
db.prepare('UPDATE webhooks SET avatar_url = ? WHERE id = ?').run(av || null, webhookId);
}
if (data.callback_url !== undefined) {
let cbUrl = typeof data.callback_url === 'string' ? data.callback_url.trim().slice(0, 1024) : null;
if (cbUrl && !/^https?:\/\//i.test(cbUrl)) cbUrl = null;
db.prepare('UPDATE webhooks SET callback_url = ? WHERE id = ?').run(cbUrl || null, webhookId);
}
if (data.callback_secret !== undefined) {
const secret = typeof data.callback_secret === 'string' ? data.callback_secret.trim().slice(0, 256) : null;
db.prepare('UPDATE webhooks SET callback_secret = ? WHERE id = ?').run(secret || null, webhookId);
}
const webhooks = db.prepare(`
SELECT w.id, w.channel_id, w.name, w.token, w.avatar_url, w.is_active, w.created_at,
w.callback_url, w.callback_secret,
c.name as channel_name, c.code as channel_code
FROM webhooks w JOIN channels c ON w.channel_id = c.id
ORDER BY w.created_at DESC
`).all();
socket.emit('webhooks-list', { webhooks });
socket.emit('bot-updated', 'Bot updated');
});
// ── Get all members ─────────────────────────────────────
socket.on('get-all-members', (data, callback) => {
const cb = typeof callback === 'function' ? callback : () => {};
const isAdmin = socket.user.isAdmin;
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');
let channelOnly = null;
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 {
let users;
if (channelOnly) {
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();
}
const onlineIds = new Set();
for (const [, s] of io.of('/').sockets) {
if (s.user) onlineIds.add(s.user.id);
}
const roleRows = db.prepare(`
SELECT ur.user_id, r.id as role_id, r.name, r.level, r.color
FROM user_roles ur JOIN roles r ON ur.role_id = r.id
WHERE ur.channel_id IS NULL ORDER BY r.level DESC
`).all();
const userRoles = {};
roleRows.forEach(r => {
if (!userRoles[r.user_id]) userRoles[r.user_id] = [];
userRoles[r.user_id].push({ id: r.role_id, name: r.name, level: r.level, color: r.color });
});
const bannedRows = db.prepare('SELECT user_id FROM bans').all();
const bannedIds = new Set(bannedRows.map(r => r.user_id));
const channelCounts = {};
const ccRows = db.prepare('SELECT user_id, COUNT(*) as cnt FROM channel_members GROUP BY user_id').all();
ccRows.forEach(r => { channelCounts[r.user_id] = r.cnt; });
let allChannels = [];
if (canMod) {
allChannels = db.prepare('SELECT id, name, code, parent_channel_id FROM channels WHERE is_dm = 0 ORDER BY position, name').all()
.map(c => ({ id: c.id, name: c.name, code: c.code, parentId: c.parent_channel_id }));
}
const userChannelMap = {};
if (canMod) {
const cmRows = db.prepare(`
SELECT cm.user_id, cm.channel_id, c.name as channel_name, c.code as channel_code
FROM channel_members cm JOIN channels c ON cm.channel_id = c.id WHERE c.is_dm = 0
`).all();
cmRows.forEach(r => {
if (!userChannelMap[r.user_id]) userChannelMap[r.user_id] = [];
userChannelMap[r.user_id].push({ id: r.channel_id, name: r.channel_name, code: r.channel_code });
});
}
const members = users.map(u => ({
id: u.id, username: u.username, displayName: u.displayName,
isAdmin: !!u.is_admin, online: onlineIds.has(u.id),
banned: bannedIds.has(u.id), roles: userRoles[u.id] || [],
channels: channelCounts[u.id] || 0,
channelList: canMod ? (userChannelMap[u.id] || []) : undefined,
avatar: u.avatar || null, avatarShape: u.avatar_shape || 'circle',
status: u.status || 'online', statusText: u.status_text || '',
createdAt: u.created_at
}));
cb({
members, total: members.length, channelOnly: !!channelOnly,
allChannels: canMod ? allChannels : undefined,
callerPerms: {
isAdmin, canMod,
canPromote: isAdmin || userHasPermission(socket.user.id, 'promote_user'),
canKick: isAdmin || userHasPermission(socket.user.id, 'kick_user'),
canBan: isAdmin || userHasPermission(socket.user.id, 'ban_user'),
}
});
} catch (err) {
console.error('get-all-members error:', err);
cb({ error: 'Failed to load members' });
}
});
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
// ── Pure utilities and constants (no io/db dependency) ──
// Normalize SQLite timestamps to UTC ISO 8601
// SQLite CURRENT_TIMESTAMP produces UTC without 'Z' suffix;
// browsers mis-interpret bare datetime strings as local time.
function utcStamp(s) {
if (!s || s.endsWith('Z')) return s;
return s.replace(' ', 'T') + 'Z';
}
// ── Input validation helpers ────────────────────────────
function isString(v, min = 0, max = Infinity) {
return typeof v === 'string' && v.length >= min && v.length <= max;
}
function isInt(v) {
return Number.isInteger(v);
}
// ── Server-side HTML sanitization (strip dangerous tags/attrs) ──
// Belt-and-suspenders: client escapes HTML, but server strips anything that
// could be rendered as executable HTML in case of client-side bugs.
function sanitizeText(str) {
if (typeof str !== 'string') return '';
// Strip dangerous HTML tags/attributes as defense-in-depth.
// Do NOT entity-encode here — the client handles its own escaping when
// rendering via _escapeHtml(). Entity-encoding on the server would cause
// double-encoding (e.g. ' → &#39; stored → &amp;#39; after client escape).
return str
.replace(/<script[\s>][\s\S]*?<\/script>/gi, '')
.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, '')
.replace(/<object[\s>][\s\S]*?<\/object>/gi, '')
.replace(/<embed[\s>][\s\S]*?(?:\/>|>)/gi, '')
.replace(/<style[\s>][\s\S]*?<\/style>/gi, '')
.replace(/<meta[\s>][\s\S]*?(?:\/>|>)/gi, '')
.replace(/<form[\s>][\s\S]*?<\/form>/gi, '')
.replace(/<link[\s>][\s\S]*?(?:\/>|>)/gi, '')
.replace(/\bon\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/javascript\s*:/gi, '');
}
// ── Validate /uploads/ path (prevent path traversal) ──
function isValidUploadPath(value) {
if (!value || typeof value !== 'string') return false;
// Must start with /uploads/ and contain only safe filename characters (no ../ or special chars)
return /^\/uploads\/[\w\-.]+$/.test(value);
}
// All recognized role permissions. Any permission sent by a client that is not here is silently rejected.
const VALID_ROLE_PERMS = [
'edit_own_messages', 'delete_own_messages', 'delete_message', 'delete_lower_messages',
'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', 'view_channel_members', 'manage_emojis', 'manage_soundboard', 'manage_music_queue',
'promote_user', 'transfer_admin', 'manage_roles', 'manage_server', 'delete_channel', 'read_only_override'
];
module.exports = { utcStamp, isString, isInt, sanitizeText, isValidUploadPath, VALID_ROLE_PERMS };

1118
src/socketHandlers/index.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,459 @@
'use strict';
const bcrypt = require('bcryptjs');
const { utcStamp, isInt } = require('./helpers');
module.exports = function register(socket, ctx) {
const { io, db, state, userHasPermission, getUserEffectiveLevel,
emitOnlineUsers, broadcastVoiceUsers, getEnrichedChannels } = ctx;
const { channelUsers, voiceUsers } = state;
// Helper: run an UPDATE only if the target table exists (avoids crash on
// tables that haven't been created yet, e.g. uploads, channel_emojis).
const _tableExists = {};
function updateIfTableExists(table, sql, ...params) {
if (_tableExists[table] === undefined) {
_tableExists[table] = !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
}
if (_tableExists[table]) db.prepare(sql).run(...params);
}
// ── Kick user ───────────────────────────────────────────
socket.on('kick-user', (data) => {
if (!data || typeof data !== 'object') return;
const kickCode = socket.currentChannel;
const kickCh = kickCode ? db.prepare('SELECT id FROM channels WHERE code = ?').get(kickCode) : null;
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'kick_user', kickCh ? kickCh.id : null)) {
return socket.emit('error-msg', 'You don\'t have permission to kick users');
}
if (!isInt(data.userId)) return;
if (data.userId === socket.user.id) {
return socket.emit('error-msg', 'You can\'t kick yourself');
}
if (!socket.user.isAdmin) {
const myLevel = getUserEffectiveLevel(socket.user.id, kickCh ? kickCh.id : null);
const targetLevel = getUserEffectiveLevel(data.userId, kickCh ? kickCh.id : null);
if (targetLevel >= myLevel) {
return socket.emit('error-msg', 'You can\'t kick a user with equal or higher rank');
}
}
const code = socket.currentChannel;
if (!code) return;
const channelRoom = channelUsers.get(code);
const targetInfo = channelRoom ? channelRoom.get(data.userId) : null;
if (!targetInfo) {
return socket.emit('error-msg', 'User is not currently online in this channel (use ban instead)');
}
if (kickCh) {
db.prepare('DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?').run(kickCh.id, data.userId);
const subs = db.prepare('SELECT id FROM channels WHERE parent_channel_id = ?').all(kickCh.id);
const delSub = db.prepare('DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?');
subs.forEach(s => delSub.run(s.id, data.userId));
}
io.to(targetInfo.socketId).emit('kicked', {
channelCode: code,
reason: typeof data.reason === 'string' ? data.reason.trim().slice(0, 200) : ''
});
const targetSockets = [...io.sockets.sockets.values()].filter(s => s.user && s.user.id === data.userId);
for (const ts of targetSockets) {
ts.leave(`channel:${code}`);
if (kickCh) {
const subs = db.prepare('SELECT code FROM channels WHERE parent_channel_id = ?').all(kickCh.id);
subs.forEach(sub => ts.leave(`channel:${sub.code}`));
}
ts.emit('channels-list', getEnrichedChannels(data.userId, false, (room) => ts.join(room)));
}
channelRoom.delete(data.userId);
const online = Array.from(channelRoom.values()).map(u => ({
id: u.id, username: u.username
}));
io.to(`channel:${code}`).emit('online-users', {
channelCode: code,
users: online
});
io.to(`channel:${code}`).emit('new-message', {
channelCode: code,
message: {
id: 0, content: `${targetInfo.username} was kicked`, created_at: new Date().toISOString(),
username: 'System', user_id: 0, reply_to: null, replyContext: null, reactions: [], edited_at: null, system: true
}
});
if (data.scrubMessages) {
const scrubScope = (socket.user.isAdmin && data.scrubScope === 'server') ? 'server' : 'channel';
if (scrubScope === 'channel' && kickCh) {
db.prepare('DELETE FROM reactions WHERE user_id = ? AND message_id IN (SELECT id FROM messages WHERE channel_id = ? AND is_archived = 0)').run(data.userId, kickCh.id);
db.prepare('DELETE FROM messages WHERE user_id = ? AND channel_id = ? AND is_archived = 0').run(data.userId, kickCh.id);
} else if (scrubScope === 'server') {
db.prepare('DELETE FROM reactions WHERE user_id = ? AND message_id IN (SELECT id FROM messages WHERE user_id = ? AND is_archived = 0)').run(data.userId, data.userId);
db.prepare('DELETE FROM messages WHERE user_id = ? AND is_archived = 0').run(data.userId);
}
}
socket.emit('error-msg', `Kicked ${targetInfo.username}`);
});
// ── Ban user ────────────────────────────────────────────
socket.on('ban-user', (data) => {
if (!data || typeof data !== 'object') return;
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'ban_user')) {
return socket.emit('error-msg', 'You don\'t have permission to ban users');
}
if (!isInt(data.userId)) return;
if (data.userId === socket.user.id) {
return socket.emit('error-msg', 'You can\'t ban yourself');
}
const targetRow = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(data.userId);
if (targetRow && targetRow.is_admin && !socket.user.isAdmin) {
return socket.emit('error-msg', 'You cannot ban an admin');
}
if (!socket.user.isAdmin) {
const myLevel = getUserEffectiveLevel(socket.user.id);
const targetLevel = getUserEffectiveLevel(data.userId);
if (targetLevel >= myLevel) {
return socket.emit('error-msg', 'You can\'t ban a user with equal or higher rank');
}
}
const reason = typeof data.reason === 'string' ? data.reason.trim().slice(0, 200) : '';
const targetUser = db.prepare('SELECT id, COALESCE(display_name, username) as username FROM users WHERE id = ?').get(data.userId);
if (!targetUser) return socket.emit('error-msg', 'User not found');
try {
db.prepare(
'INSERT OR REPLACE INTO bans (user_id, banned_by, reason) VALUES (?, ?, ?)'
).run(data.userId, socket.user.id, reason);
} catch (err) {
console.error('Ban error:', err);
return socket.emit('error-msg', 'Failed to ban user');
}
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === data.userId) {
s.emit('banned', { reason });
s.disconnect(true);
}
}
for (const [code] of channelUsers) {
emitOnlineUsers(code);
}
if (data.scrubMessages) {
db.prepare('DELETE FROM reactions WHERE user_id = ? AND message_id IN (SELECT id FROM messages WHERE user_id = ? AND is_archived = 0)').run(data.userId, data.userId);
db.prepare('DELETE FROM messages WHERE user_id = ? AND is_archived = 0').run(data.userId);
}
socket.emit('error-msg', `Banned ${targetUser.username}`);
});
// ── Unban user ──────────────────────────────────────────
socket.on('unban-user', (data) => {
if (!data || typeof data !== 'object') return;
if (!socket.user.isAdmin) {
return socket.emit('error-msg', 'Only admins can unban users');
}
if (!isInt(data.userId)) return;
db.prepare('DELETE FROM bans WHERE user_id = ?').run(data.userId);
const targetUser = db.prepare('SELECT COALESCE(display_name, username) as username FROM users WHERE id = ?').get(data.userId);
socket.emit('error-msg', `Unbanned ${targetUser ? targetUser.username : 'user'}`);
const bans = db.prepare(`
SELECT b.id, b.user_id, b.reason, b.created_at, COALESCE(u.display_name, u.username) as username
FROM bans b JOIN users u ON b.user_id = u.id ORDER BY b.created_at DESC
`).all();
bans.forEach(b => { b.created_at = utcStamp(b.created_at); });
socket.emit('ban-list', bans);
});
// ── Delete user (admin purge) ───────────────────────────
socket.on('delete-user', (data) => {
if (!data || typeof data !== 'object') return;
if (!socket.user.isAdmin) {
return socket.emit('error-msg', 'Only admins can delete users');
}
if (!isInt(data.userId)) return;
if (data.userId === socket.user.id) {
return socket.emit('error-msg', 'You can\'t delete yourself');
}
const targetUser = db.prepare('SELECT id, username, display_name, COALESCE(display_name, username) as displayName FROM users WHERE id = ?').get(data.userId);
if (!targetUser) return socket.emit('error-msg', 'User not found');
const reason = typeof data.reason === 'string' ? data.reason.trim().slice(0, 500) : '';
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === data.userId) {
s.emit('banned', { reason: 'Your account has been deleted by an admin.' });
s.disconnect(true);
}
}
for (const [code, users] of channelUsers) {
if (users.has(data.userId)) {
users.delete(data.userId);
emitOnlineUsers(code);
}
}
for (const [code, users] of voiceUsers) {
if (users.has(data.userId)) {
users.delete(data.userId);
broadcastVoiceUsers(code);
}
}
const purge = db.transaction((uid) => {
db.prepare('DELETE FROM reactions WHERE user_id = ?').run(uid);
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);
db.prepare('DELETE FROM user_roles WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM read_positions WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM fcm_tokens WHERE user_id = ?').run(uid);
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);
db.prepare('UPDATE channels SET created_by = NULL WHERE created_by = ?').run(uid);
updateIfTableExists('uploads', 'UPDATE uploads SET uploaded_by = NULL WHERE uploaded_by = ?', uid);
updateIfTableExists('channel_emojis', 'UPDATE channel_emojis SET uploaded_by = NULL WHERE uploaded_by = ?', uid);
db.prepare('UPDATE bans SET banned_by = ? WHERE banned_by = ?').run(socket.user.id, uid);
db.prepare('UPDATE mutes SET muted_by = ? WHERE muted_by = ?').run(socket.user.id, uid);
db.prepare('UPDATE user_roles SET granted_by = NULL WHERE granted_by = ?').run(uid);
updateIfTableExists('webhook_configs', 'UPDATE webhook_configs SET created_by = NULL WHERE created_by = ?', uid);
db.prepare('UPDATE whitelist SET added_by = NULL WHERE added_by = ?').run(uid);
db.prepare('UPDATE deleted_users SET deleted_by = NULL WHERE deleted_by = ?').run(uid);
if (data.scrubMessages) {
db.prepare('DELETE FROM pinned_messages WHERE message_id IN (SELECT id FROM messages WHERE user_id = ? AND is_archived = 0)').run(uid);
db.prepare('DELETE FROM messages WHERE user_id = ? AND is_archived = 0').run(uid);
db.prepare('UPDATE messages SET user_id = NULL WHERE user_id = ?').run(uid);
} else {
db.prepare('UPDATE messages SET user_id = NULL WHERE user_id = ?').run(uid);
}
db.prepare('DELETE FROM users WHERE id = ?').run(uid);
db.prepare('INSERT INTO deleted_users (username, display_name, reason, deleted_by) VALUES (?, ?, ?, ?)').run(
targetUser.username, targetUser.display_name, reason, socket.user.id
);
});
try {
purge(data.userId);
} catch (err) {
console.error('Delete user error:', err);
return socket.emit('error-msg', 'Failed to delete user');
}
socket.emit('error-msg', `Deleted user "${targetUser.displayName}" — username is now available`);
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.isAdmin) {
s.emit('user-deleted', { userId: data.userId, username: targetUser.displayName });
}
}
const bans = db.prepare(`
SELECT b.id, b.user_id, b.reason, b.created_at, COALESCE(u.display_name, u.username) as username
FROM bans b JOIN users u ON b.user_id = u.id ORDER BY b.created_at DESC
`).all();
bans.forEach(b => { b.created_at = utcStamp(b.created_at); });
socket.emit('ban-list', bans);
console.log(`🗑️ Admin deleted user "${targetUser.displayName}" (id: ${data.userId})`);
});
// ── Self-delete account ─────────────────────────────────
socket.on('self-delete-account', async (data, callback) => {
if (!data || typeof data !== 'object') return;
const cb = typeof callback === 'function' ? callback : () => {};
const uid = socket.user.id;
if (socket.user.isAdmin) {
return cb({ error: 'Admins must transfer admin to another user before deleting their account' });
}
const password = typeof data.password === 'string' ? data.password : '';
if (!password) return cb({ error: 'Password is required' });
const userRow = db.prepare('SELECT password_hash, COALESCE(display_name, username) as username FROM users WHERE id = ?').get(uid);
if (!userRow) return cb({ error: 'User not found' });
let validPw;
try {
validPw = await bcrypt.compare(password, userRow.password_hash);
if (!validPw) return cb({ error: 'Incorrect password' });
} catch (err) {
console.error('Self-delete password verification error:', err);
return cb({ error: 'Password verification failed' });
}
const scrubMessages = !!data.scrubMessages;
for (const [code, users] of channelUsers) {
if (users.has(uid)) {
users.delete(uid);
emitOnlineUsers(code);
}
}
for (const [code, users] of voiceUsers) {
if (users.has(uid)) {
users.delete(uid);
broadcastVoiceUsers(code);
}
}
const purge = db.transaction(() => {
db.prepare('DELETE FROM reactions WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM mutes WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM bans WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM user_roles WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM read_positions WHERE user_id = ?').run(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);
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM fcm_tokens WHERE user_id = ?').run(uid);
db.prepare('UPDATE channels SET created_by = NULL WHERE created_by = ?').run(uid);
updateIfTableExists('uploads', 'UPDATE uploads SET uploaded_by = NULL WHERE uploaded_by = ?', uid);
updateIfTableExists('channel_emojis', 'UPDATE channel_emojis SET uploaded_by = NULL WHERE uploaded_by = ?', uid);
db.prepare('UPDATE bans SET banned_by = NULL WHERE banned_by = ?').run(uid);
db.prepare('UPDATE mutes SET muted_by = NULL WHERE muted_by = ?').run(uid);
db.prepare('UPDATE user_roles SET granted_by = NULL WHERE granted_by = ?').run(uid);
updateIfTableExists('webhook_configs', 'UPDATE webhook_configs SET created_by = NULL WHERE created_by = ?', uid);
db.prepare('UPDATE whitelist SET added_by = NULL WHERE added_by = ?').run(uid);
db.prepare('UPDATE deleted_users SET deleted_by = NULL WHERE deleted_by = ?').run(uid);
db.prepare('UPDATE pinned_messages SET pinned_by = NULL WHERE pinned_by = ?').run(uid);
if (scrubMessages) {
db.prepare('DELETE FROM pinned_messages WHERE message_id IN (SELECT id FROM messages WHERE user_id = ? AND is_archived = 0)').run(uid);
db.prepare('DELETE FROM messages WHERE user_id = ? AND is_archived = 0').run(uid);
db.prepare('UPDATE messages SET user_id = NULL WHERE user_id = ?').run(uid);
const dmChannels = db.prepare(`
SELECT c.id, c.code FROM channels c
JOIN channel_members cm ON c.id = cm.channel_id
WHERE c.is_dm = 1 AND cm.user_id = ?
`).all(uid);
for (const dm of dmChannels) {
const remaining = db.prepare('SELECT COUNT(*) as cnt FROM messages WHERE channel_id = ?').get(dm.id);
if (remaining.cnt === 0) {
db.prepare('DELETE FROM channel_members WHERE channel_id = ?').run(dm.id);
db.prepare('DELETE FROM read_positions WHERE channel_id = ?').run(dm.id);
db.prepare('DELETE FROM channels WHERE id = ?').run(dm.id);
}
}
} else {
db.prepare('UPDATE messages SET user_id = NULL WHERE user_id = ?').run(uid);
}
db.prepare('DELETE FROM channel_members WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM users WHERE id = ?').run(uid);
});
try {
purge();
} catch (err) {
console.error('Self-delete error:', err);
return cb({ error: 'Failed to delete account' });
}
console.log(`🗑️ User self-deleted: "${userRow.username}" (id: ${uid}, scrub: ${scrubMessages})`);
cb({ success: true });
socket.disconnect(true);
});
// ── Mute / unmute ───────────────────────────────────────
socket.on('mute-user', (data) => {
if (!data || typeof data !== 'object') return;
const muteCode = socket.currentChannel;
const muteCh = muteCode ? db.prepare('SELECT id FROM channels WHERE code = ?').get(muteCode) : null;
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'mute_user', muteCh ? muteCh.id : null)) {
return socket.emit('error-msg', 'You don\'t have permission to mute users');
}
if (!isInt(data.userId)) return;
if (data.userId === socket.user.id) {
return socket.emit('error-msg', 'You can\'t mute yourself');
}
if (!socket.user.isAdmin) {
const myLevel = getUserEffectiveLevel(socket.user.id, muteCh ? muteCh.id : null);
const targetLevel = getUserEffectiveLevel(data.userId, muteCh ? muteCh.id : null);
if (targetLevel >= myLevel) {
return socket.emit('error-msg', 'You can\'t mute a user with equal or higher rank');
}
}
const durationMinutes = isInt(data.duration) && data.duration > 0 && data.duration <= 43200
? data.duration : 10;
const reason = typeof data.reason === 'string' ? data.reason.trim().slice(0, 200) : '';
const targetUser = db.prepare('SELECT COALESCE(display_name, username) as username FROM users WHERE id = ?').get(data.userId);
if (!targetUser) return socket.emit('error-msg', 'User not found');
try {
db.prepare(
'INSERT INTO mutes (user_id, muted_by, reason, expires_at) VALUES (?, ?, ?, datetime(\'now\', ?))'
).run(data.userId, socket.user.id, reason, `+${durationMinutes} minutes`);
} catch (err) {
console.error('Mute error:', err);
return socket.emit('error-msg', 'Failed to mute user');
}
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === data.userId) {
s.emit('muted', { duration: durationMinutes, reason });
}
}
socket.emit('error-msg', `Muted ${targetUser.username} for ${durationMinutes} min`);
});
socket.on('unmute-user', (data) => {
if (!data || typeof data !== 'object') return;
if (!socket.user.isAdmin) {
return socket.emit('error-msg', 'Only admins can unmute users');
}
if (!isInt(data.userId)) return;
db.prepare('DELETE FROM mutes WHERE user_id = ?').run(data.userId);
const targetUser = db.prepare('SELECT COALESCE(display_name, username) as username FROM users WHERE id = ?').get(data.userId);
socket.emit('error-msg', `Unmuted ${targetUser ? targetUser.username : 'user'}`);
});
// ── Ban / deleted-user lists ────────────────────────────
socket.on('get-bans', () => {
if (!socket.user.isAdmin) return;
const bans = db.prepare(`
SELECT b.id, b.user_id, b.reason, b.created_at, COALESCE(u.display_name, u.username) as username
FROM bans b JOIN users u ON b.user_id = u.id ORDER BY b.created_at DESC
`).all();
bans.forEach(b => { b.created_at = utcStamp(b.created_at); });
socket.emit('ban-list', bans);
});
socket.on('get-deleted-users', () => {
if (!socket.user.isAdmin) return;
const rows = db.prepare(`
SELECT d.id, d.username, d.display_name, d.reason, d.deleted_at,
COALESCE(u.display_name, u.username) as deleted_by_name
FROM deleted_users d
LEFT JOIN users u ON d.deleted_by = u.id
ORDER BY d.deleted_at DESC
`).all();
rows.forEach(r => { r.deleted_at = utcStamp(r.deleted_at); });
socket.emit('deleted-users-list', rows);
});
};

350
src/socketHandlers/music.js Normal file
View file

@ -0,0 +1,350 @@
'use strict';
const crypto = require('crypto');
const { isString, isInt } = require('./helpers');
module.exports = function register(socket, ctx) {
const { io, db, state, userHasPermission,
resolveSpotifyToYouTube, searchYouTube, fetchYouTubePlaylist, resolveMusicMetadata,
getActiveMusicSyncState, updateActiveMusicPlaybackState,
startQueuedMusic, popNextQueuedMusic, isNaturalMusicFinish,
broadcastMusicQueue, getMusicQueuePayload, sanitizeQueueEntry,
trimMusicText, stripYouTubePlaylistParam } = ctx;
const { voiceUsers, activeMusic, musicQueues } = state;
// ── Share a track ───────────────────────────────────────
socket.on('music-share', async (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
if (!isString(data.url, 1, 500)) return;
if (!/^https?:\/\//i.test(data.url)) return socket.emit('error-msg', 'Invalid URL');
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const musicChannel = db.prepare('SELECT music_enabled FROM channels WHERE code = ?').get(data.code);
if (musicChannel && musicChannel.music_enabled === 0 && !socket.user.isAdmin) {
return socket.emit('error-msg', 'Music sharing is disabled in this channel');
}
let playUrl = stripYouTubePlaylistParam(data.url);
let resolvedFrom = null;
let title = trimMusicText(data.title, 200);
const isSpotify = /open\.spotify\.com\/(track|album|playlist|episode|show)\/[a-zA-Z0-9]+/.test(data.url);
if (isSpotify) {
const resolved = await resolveSpotifyToYouTube(data.url);
if (resolved?.url) {
playUrl = resolved.url;
resolvedFrom = 'spotify';
if (!title) title = trimMusicText(resolved.title, 200);
} else {
return socket.emit('error-msg', 'Could not resolve Spotify link to YouTube. Try sharing a YouTube link directly.');
}
}
if (!title) {
const resolvedMeta = await resolveMusicMetadata(playUrl);
title = trimMusicText(resolvedMeta.title, 200);
}
const entry = sanitizeQueueEntry({
id: crypto.randomBytes(12).toString('hex'),
url: playUrl,
title: title || 'Shared track',
userId: socket.user.id,
username: socket.user.displayName,
resolvedFrom
});
if (!entry) return;
if (!activeMusic.get(data.code)) {
startQueuedMusic(data.code, entry);
return;
}
const queue = musicQueues.get(data.code) || [];
queue.push(entry);
musicQueues.set(data.code, queue);
broadcastMusicQueue(data.code);
io.to(`voice:${data.code}`).emit('toast', {
message: `${entry.username} queued ${entry.title}`,
type: 'info'
});
});
// ── Share a playlist ────────────────────────────────────
socket.on('music-share-playlist', async (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
if (!isString(data.playlistId, 1, 200)) return;
if (!/^[a-zA-Z0-9_-]+$/.test(data.playlistId)) return socket.emit('error-msg', 'Invalid playlist ID');
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const musicChannel = db.prepare('SELECT music_enabled FROM channels WHERE code = ?').get(data.code);
if (musicChannel && musicChannel.music_enabled === 0 && !socket.user.isAdmin) {
return socket.emit('error-msg', 'Music sharing is disabled in this channel');
}
socket.emit('toast', { message: 'Fetching playlist…', type: 'info' });
const tracks = await fetchYouTubePlaylist(data.playlistId);
if (!tracks.length) {
return socket.emit('error-msg', 'Could not fetch playlist or it is empty');
}
let addedCount = 0;
for (const track of tracks) {
const url = `https://www.youtube.com/watch?v=${track.videoId}`;
const entry = sanitizeQueueEntry({
id: crypto.randomBytes(12).toString('hex'),
url,
title: trimMusicText(track.title, 200) || 'Untitled track',
userId: socket.user.id,
username: socket.user.displayName,
resolvedFrom: null
});
if (!entry) continue;
if (!activeMusic.get(data.code) && addedCount === 0) {
startQueuedMusic(data.code, entry);
} else {
const queue = musicQueues.get(data.code) || [];
queue.push(entry);
musicQueues.set(data.code, queue);
}
addedCount++;
}
if (addedCount > 0) {
broadcastMusicQueue(data.code);
io.to(`voice:${data.code}`).emit('toast', {
message: `${socket.user.displayName} added ${addedCount} track${addedCount !== 1 ? 's' : ''} from a playlist`,
type: 'info'
});
} else {
socket.emit('error-msg', 'No playable tracks found in playlist');
}
});
// ── Stop music ──────────────────────────────────────────
socket.on('music-stop', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const current = activeMusic.get(data.code);
if (!current) return;
if (socket.user.id !== current.userId && !socket.user.isAdmin) {
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(data.code);
if (!channel || !userHasPermission(socket.user.id, 'manage_music_queue', channel.id)) {
return socket.emit('error-msg', 'Only the requestor or a moderator can stop playback');
}
}
activeMusic.delete(data.code);
musicQueues.delete(data.code);
for (const [uid, user] of voiceRoom) {
io.to(user.socketId).emit('music-stopped', {
userId: socket.user.id,
username: socket.user.displayName,
channelCode: data.code
});
}
broadcastMusicQueue(data.code);
});
// ── Play / pause / next / prev / shuffle control ────────
socket.on('music-control', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
const action = data.action;
const allowed = ['play', 'pause', 'next', 'prev', 'shuffle'];
if (!allowed.includes(action)) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const current = activeMusic.get(data.code);
if (!current) return;
if (socket.user.id !== current.userId && !socket.user.isAdmin) {
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(data.code);
if (!channel || !userHasPermission(socket.user.id, 'manage_music_queue', channel.id)) {
const label = (action === 'play' || action === 'pause') ? 'pause/resume playback' : 'skip tracks';
return socket.emit('error-msg', `Only the requestor or a moderator can ${label}`);
}
}
const rawPosition = Number(data.positionSeconds);
const rawDuration = Number(data.durationSeconds);
const syncState = updateActiveMusicPlaybackState(data.code, {
isPlaying: action === 'play' ? true : action === 'pause' ? false : undefined,
positionSeconds: Number.isFinite(rawPosition) ? rawPosition : undefined,
durationSeconds: Number.isFinite(rawDuration) && rawDuration >= 0 ? rawDuration : undefined
});
for (const [uid, user] of voiceRoom) {
if (uid === socket.user.id) continue;
io.to(user.socketId).emit('music-control', {
action,
userId: socket.user.id,
username: socket.user.displayName,
channelCode: data.code,
syncState
});
}
});
// ── Seek ────────────────────────────────────────────────
socket.on('music-seek', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const current = activeMusic.get(data.code);
if (!current) return;
if (socket.user.id !== current.userId && !socket.user.isAdmin) {
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(data.code);
if (!channel || !userHasPermission(socket.user.id, 'manage_music_queue', channel.id)) {
return socket.emit('error-msg', 'Only the requestor or a moderator can seek');
}
}
const rawDuration = Number(data.durationSeconds);
const durationSeconds = Number.isFinite(rawDuration) && rawDuration >= 0 ? rawDuration : undefined;
let positionSeconds = Number(data.positionSeconds);
if (!Number.isFinite(positionSeconds)) {
const positionPct = Number(data.position);
if (!Number.isFinite(positionPct) || positionPct < 0 || positionPct > 100 || !Number.isFinite(durationSeconds)) return;
positionSeconds = (durationSeconds * positionPct) / 100;
}
const syncState = updateActiveMusicPlaybackState(data.code, {
positionSeconds,
durationSeconds
});
for (const [uid, user] of voiceRoom) {
if (uid === socket.user.id) continue;
io.to(user.socketId).emit('music-seek', {
position: syncState && Number.isFinite(syncState.durationSeconds) && syncState.durationSeconds > 0
? (syncState.positionSeconds / syncState.durationSeconds) * 100
: undefined,
positionSeconds: syncState ? syncState.positionSeconds : positionSeconds,
durationSeconds: syncState ? syncState.durationSeconds : (durationSeconds ?? null),
userId: socket.user.id,
username: socket.user.displayName,
channelCode: data.code,
syncState
});
}
});
// ── Track finished ──────────────────────────────────────
socket.on('music-finished', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const current = activeMusic.get(data.code);
if (!current) return;
const trackId = trimMusicText(data.trackId, 64);
if (!trackId || !current.id || trackId !== current.id) return;
const isPrivileged = socket.user.id === current.userId || socket.user.isAdmin || (() => {
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(data.code);
return !!channel && userHasPermission(socket.user.id, 'manage_music_queue', channel.id);
})();
if (data.isSkip) {
if (!isPrivileged) {
return socket.emit('error-msg', 'Only the requestor or a moderator can skip tracks');
}
} else if (!isPrivileged && !isNaturalMusicFinish(current, Number(data.positionSeconds), Number(data.durationSeconds))) {
return;
}
const next = popNextQueuedMusic(data.code);
if (next) {
startQueuedMusic(data.code, next);
return;
}
activeMusic.delete(data.code);
for (const [, user] of voiceRoom) {
io.to(user.socketId).emit('music-stopped', {
userId: current.userId,
username: current.username,
channelCode: data.code
});
}
broadcastMusicQueue(data.code);
});
// ── Queue management ────────────────────────────────────
socket.on('music-queue-remove', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8) || !isString(data.entryId, 1, 64)) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(data.code);
if (!channel) return;
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_music_queue', channel.id)) {
return socket.emit('error-msg', 'You do not have permission to manage the music queue');
}
const queue = musicQueues.get(data.code) || [];
const nextQueue = queue.filter(item => item.id !== data.entryId);
if (nextQueue.length > 0) musicQueues.set(data.code, nextQueue);
else musicQueues.delete(data.code);
broadcastMusicQueue(data.code);
});
socket.on('music-queue-reorder', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8) || !Array.isArray(data.entryIds)) return;
if (data.entryIds.length > 200) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(data.code);
if (!channel) return;
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_music_queue', channel.id)) {
return socket.emit('error-msg', 'You do not have permission to manage the music queue');
}
const queue = musicQueues.get(data.code) || [];
if (queue.length < 2) return;
const byId = new Map(queue.map(item => [item.id, item]));
const reordered = [];
for (const entryId of data.entryIds.map(id => trimMusicText(id, 64))) {
const item = byId.get(entryId);
if (item) reordered.push(item);
}
if (reordered.length !== queue.length) return;
musicQueues.set(data.code, reordered);
broadcastMusicQueue(data.code);
});
socket.on('music-queue-shuffle', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(data.code);
if (!channel) return;
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_music_queue', channel.id)) {
return socket.emit('error-msg', 'You do not have permission to manage the music queue');
}
const queue = musicQueues.get(data.code) || [];
if (queue.length < 2) return;
for (let i = queue.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[queue[i], queue[j]] = [queue[j], queue[i]];
}
musicQueues.set(data.code, queue);
broadcastMusicQueue(data.code);
});
// ── Search ──────────────────────────────────────────────
socket.on('music-search', async (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.query, 1, 200)) return;
const offset = isInt(data.offset) && data.offset >= 0 ? data.offset : 0;
try {
const results = await searchYouTube(data.query, 5, offset);
socket.emit('music-search-results', {
results,
query: data.query,
offset
});
} catch {
socket.emit('music-search-results', { results: [], query: data.query, offset });
}
});
};

View file

@ -0,0 +1,288 @@
// ── YouTube / Spotify resolution (no io/db dependency) ──
// ── Spotify → YouTube resolution ──────────────────────────
// Spotify embeds only give 30-second previews to non-premium users
// and have no external JS API for sync/volume. We resolve the track
// title via Spotify oEmbed, then find it on YouTube for full playback.
async function resolveSpotifyToYouTube(spotifyUrl) {
try {
// 1. Get track title from Spotify oEmbed (no auth needed)
const oembedRes = await fetch(
`https://open.spotify.com/oembed?url=${encodeURIComponent(spotifyUrl)}`
);
if (!oembedRes.ok) return null;
const oembed = await oembedRes.json();
const title = oembed.title; // e.g. "Thank You - Dido"
if (!title) return null;
// 2. Search YouTube — try refined query first, then broader
const queries = [
title + ' official audio',
title + ' audio',
title
];
for (const q of queries) {
const results = await searchYouTube(q, 1);
if (results.length > 0) {
return {
url: `https://www.youtube.com/watch?v=${results[0].videoId}`,
title,
duration: results[0].duration || ''
};
}
}
return null;
} catch {
return null;
}
}
// ── YouTube search helper ─────────────────────────────────
// Uses YouTube's InnerTube API (primary) with HTML scraping fallback.
// Returns array of { videoId, title, channel, duration, thumbnail }
const YT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
async function searchYouTube(query, count = 5, offset = 0) {
// ── Method 1: InnerTube API (structured, reliable) ──────────
try {
const resp = await fetch('https://www.youtube.com/youtubei/v1/search?prettyPrint=false', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': YT_UA
},
body: JSON.stringify({
query,
context: {
client: { clientName: 'WEB', clientVersion: '2.20241120.01.00', hl: 'en', gl: 'US' }
},
params: 'EgIQAQ%3D%3D' // filter: videos only
})
});
if (resp.ok) {
const data = await resp.json();
const contents = data?.contents?.twoColumnSearchResultsRenderer
?.primaryContents?.sectionListRenderer?.contents;
if (contents) {
const videos = [];
for (const section of contents) {
const items = section?.itemSectionRenderer?.contents;
if (!items) continue;
for (const item of items) {
const vr = item.videoRenderer;
if (!vr || !vr.videoId) continue;
videos.push({
videoId: vr.videoId,
title: vr.title?.runs?.[0]?.text || 'Unknown',
channel: vr.ownerText?.runs?.[0]?.text || '',
duration: vr.lengthText?.simpleText || '',
thumbnail: vr.thumbnail?.thumbnails?.[0]?.url || ''
});
}
}
if (videos.length > 0) return videos.slice(offset, offset + count);
}
}
} catch { /* InnerTube failed, fall through to HTML scraping */ }
// ── Method 2: HTML scraping (legacy fallback) ───────────────
try {
const res = await fetch(
`https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`,
{ headers: { 'User-Agent': YT_UA } }
);
const html = await res.text();
// Extract ytInitialData JSON which contains structured search results
const dataMatch = html.match(/var\s+ytInitialData\s*=\s*({.+?});\s*<\/script>/s);
if (dataMatch) {
try {
const ytData = JSON.parse(dataMatch[1]);
const contents = ytData?.contents?.twoColumnSearchResultsRenderer
?.primaryContents?.sectionListRenderer?.contents;
if (contents) {
const videos = [];
for (const section of contents) {
const items = section?.itemSectionRenderer?.contents;
if (!items) continue;
for (const item of items) {
const vr = item.videoRenderer;
if (!vr || !vr.videoId) continue;
videos.push({
videoId: vr.videoId,
title: vr.title?.runs?.[0]?.text || 'Unknown',
channel: vr.ownerText?.runs?.[0]?.text || '',
duration: vr.lengthText?.simpleText || '',
thumbnail: vr.thumbnail?.thumbnails?.[0]?.url || ''
});
}
}
if (videos.length > 0) return videos.slice(offset, offset + count);
}
} catch { /* JSON parse failed, fall through to regex */ }
}
// Fallback: regex extraction (less info, just videoId)
const matches = [...html.matchAll(/"videoId":"([a-zA-Z0-9_-]{11})"/g)];
const seen = new Set();
const results = [];
for (const m of matches) {
if (!seen.has(m[1])) {
seen.add(m[1]);
results.push({ videoId: m[1], title: '', channel: '', duration: '', thumbnail: '' });
}
}
return results.slice(offset, offset + count);
} catch {
return [];
}
}
function getYouTubeClientContext() {
return {
client: { clientName: 'WEB', clientVersion: '2.20241120.01.00', hl: 'en', gl: 'US' }
};
}
function parseYouTubePlaylistPage(data) {
const listRenderer = data?.contents?.twoColumnBrowseResultsRenderer?.tabs?.[0]
?.tabRenderer?.content?.sectionListRenderer?.contents?.[0]
?.itemSectionRenderer?.contents?.[0]?.playlistVideoListRenderer;
const items = Array.isArray(listRenderer?.contents) ? listRenderer.contents : [];
const continuation = listRenderer?.continuations?.[0]?.nextContinuationData?.continuation || null;
return { items, continuation };
}
function getContinuationItemsFromAppendAction(data) {
const appendAction = data?.onResponseReceivedActions?.find(action => action?.appendContinuationItemsAction)
?.appendContinuationItemsAction;
if (Array.isArray(appendAction?.continuationItems)) return appendAction.continuationItems;
const appendEndpoint = data?.onResponseReceivedEndpoints?.find(endpoint => endpoint?.appendContinuationItemsAction)
?.appendContinuationItemsAction;
if (Array.isArray(appendEndpoint?.continuationItems)) return appendEndpoint.continuationItems;
return [];
}
function getContinuationTokenFromItems(items) {
if (!Array.isArray(items)) return null;
const continuationItem = items.find(item => item?.continuationItemRenderer);
return continuationItem?.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token || null;
}
function getContinuationItemsFromPlaylistContents(data) {
return data?.continuationContents?.playlistVideoListContinuation?.contents || [];
}
function getContinuationTokenFromPlaylistContents(data) {
return data?.continuationContents?.playlistVideoListContinuation?.continuations?.[0]
?.nextContinuationData?.continuation || null;
}
function parseYouTubePlaylistContinuation(data) {
// InnerTube playlist continuations are not stable. Depending on client/experiment bucket, YouTube may return appended rows under response "actions", "endpoints",
//or direct "continuationContents", so we check for all of them.
const appendItems = getContinuationItemsFromAppendAction(data);
if (appendItems.length > 0) {
return {
items: appendItems,
continuation: getContinuationTokenFromItems(appendItems)
};
}
const playlistItems = getContinuationItemsFromPlaylistContents(data);
return {
items: playlistItems,
continuation: getContinuationTokenFromPlaylistContents(data)
};
}
function appendYouTubePlaylistTracks(tracks, items, maxTracks) {
if (!Array.isArray(items)) return;
for (const item of items) {
const v = item?.playlistVideoRenderer;
if (!v?.videoId) continue;
tracks.push({ videoId: v.videoId, title: v.title?.runs?.[0]?.text || '' });
if (tracks.length >= maxTracks) break;
}
}
// Pull a max of 200 tracks from a playlist provided by a user. Potentially should
// have maxTracks be a server configurable setting instead of hardcoded.
async function fetchYouTubePlaylist(playlistId, maxTracks = 200) {
try {
const resp = await fetch('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': YT_UA },
body: JSON.stringify({
browseId: 'VL' + playlistId,
context: getYouTubeClientContext()
})
});
if (!resp.ok) return [];
const data = await resp.json();
const tracks = [];
const firstPage = parseYouTubePlaylistPage(data);
appendYouTubePlaylistTracks(tracks, firstPage.items, maxTracks);
let continuation = firstPage.continuation;
while (continuation && tracks.length < maxTracks) {
const pageResp = await fetch('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': YT_UA },
body: JSON.stringify({
continuation,
context: getYouTubeClientContext()
})
});
if (!pageResp.ok) break;
const pageData = await pageResp.json();
const nextPage = parseYouTubePlaylistContinuation(pageData);
appendYouTubePlaylistTracks(tracks, nextPage.items, maxTracks);
if (!nextPage.continuation || nextPage.continuation === continuation) break;
continuation = nextPage.continuation;
}
return tracks;
} catch { return []; }
}
function extractYouTubeVideoId(url) {
if (typeof url !== 'string') return null;
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/shorts\/|music\.youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/);
return match ? match[1] : null;
}
//Grab metadata for queue and up next system
async function resolveMusicMetadata(url) {
if (!url || typeof url !== 'string') return { title: '', duration: '' };
try {
const ytId = extractYouTubeVideoId(url);
if (ytId) {
const res = await fetch(
`https://www.youtube.com/oembed?url=${encodeURIComponent(`https://www.youtube.com/watch?v=${ytId}`)}&format=json`,
{ signal: AbortSignal.timeout(5000) }
);
if (res.ok) {
const data = await res.json();
return { title: data.title || '', duration: '' };
}
}
if (url.includes('soundcloud.com/') || url.includes('spotify.com/')) {
const res = await fetch(
`https://noembed.com/embed?url=${encodeURIComponent(url)}`,
{ signal: AbortSignal.timeout(5000) }
);
if (res.ok) {
const data = await res.json();
return { title: data.title || '', duration: '' };
}
}
} catch {}
return { title: '', duration: '' };
}
module.exports = {
resolveSpotifyToYouTube, searchYouTube, fetchYouTubePlaylist,
extractYouTubeVideoId, resolveMusicMetadata
};

View file

@ -0,0 +1,179 @@
// ── Permission system helpers (factory — closes over db) ──
module.exports = function createPermissions(db) {
// ── Role inheritance: get the channel hierarchy chain for role cascading ──
// Server roles → apply everywhere (channel_id IS NULL)
// Channel role → applies to that channel + all its sub-channels
// Sub-channel role → only that sub-channel
// This returns an array of channel IDs to check (the target + its parent if it's a sub)
function getChannelRoleChain(channelId) {
if (!channelId) return [];
const ch = db.prepare('SELECT id, parent_channel_id FROM channels WHERE id = ?').get(channelId);
if (!ch) return [channelId];
if (ch.parent_channel_id) return [channelId, ch.parent_channel_id];
return [channelId];
}
function getUserEffectiveLevel(userId, channelId = null) {
const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(userId);
if (user && user.is_admin) return 100;
const serverRole = db.prepare(`
SELECT MAX(COALESCE(ur.custom_level, r.level)) as maxLevel FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = ? AND r.scope = 'server' AND ur.channel_id IS NULL
`).get(userId);
let level = (serverRole && serverRole.maxLevel) || 0;
if (channelId) {
const chain = getChannelRoleChain(channelId);
if (chain.length > 0) {
const placeholders = chain.map(() => '?').join(',');
const channelRole = db.prepare(`
SELECT MAX(COALESCE(ur.custom_level, r.level)) as maxLevel FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = ? AND ur.channel_id IN (${placeholders})
`).get(userId, ...chain);
if (channelRole && channelRole.maxLevel && channelRole.maxLevel > level) {
level = channelRole.maxLevel;
}
}
}
return level;
}
function getPermissionThresholds() {
try {
const row = db.prepare("SELECT value FROM server_settings WHERE key = 'permission_thresholds'").get();
return row ? JSON.parse(row.value) : {};
} catch { return {}; }
}
function userHasPermission(userId, permission, channelId = null) {
const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(userId);
if (user && user.is_admin) return true;
// Check per-user permission overrides first (explicit deny takes priority)
try {
const override = db.prepare(`
SELECT allowed FROM user_role_perms WHERE user_id = ? AND permission = ?
ORDER BY allowed ASC LIMIT 1
`).get(userId, permission);
if (override) {
if (override.allowed === 0) return false;
if (override.allowed === 1) return true;
}
} catch { /* table may not exist yet */ }
// Check level-based permission thresholds
const thresholds = getPermissionThresholds();
if (thresholds[permission]) {
const level = getUserEffectiveLevel(userId);
if (level >= thresholds[permission]) return true;
}
// Check server-scoped roles
const serverPerm = db.prepare(`
SELECT rp.allowed FROM role_permissions rp
JOIN roles r ON rp.role_id = r.id
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = ? AND rp.permission = ? AND r.scope = 'server' AND ur.channel_id IS NULL AND rp.allowed = 1
LIMIT 1
`).get(userId, permission);
if (serverPerm) return true;
// Check channel-scoped roles (with inheritance: parent channel roles cascade to subs)
if (channelId) {
const chain = getChannelRoleChain(channelId);
if (chain.length > 0) {
const placeholders = chain.map(() => '?').join(',');
const channelPerm = db.prepare(`
SELECT rp.allowed FROM role_permissions rp
JOIN roles r ON rp.role_id = r.id
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = ? AND rp.permission = ? AND ur.channel_id IN (${placeholders}) AND rp.allowed = 1
LIMIT 1
`).get(userId, permission, ...chain);
if (channelPerm) return true;
}
}
return false;
}
function getUserPermissions(userId) {
const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(userId);
if (user && user.is_admin) return ['*'];
const rows = db.prepare(`
SELECT DISTINCT rp.permission FROM role_permissions rp
JOIN roles r ON rp.role_id = r.id
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = ? AND rp.allowed = 1
`).all(userId);
const perms = rows.map(r => r.permission);
try {
const overrides = db.prepare(`
SELECT permission, allowed FROM user_role_perms WHERE user_id = ?
`).all(userId);
for (const ov of overrides) {
if (ov.allowed === 1 && !perms.includes(ov.permission)) {
perms.push(ov.permission);
} else if (ov.allowed === 0) {
const idx = perms.indexOf(ov.permission);
if (idx !== -1) perms.splice(idx, 1);
}
}
} catch { /* user_role_perms table may not exist yet */ }
const thresholds = getPermissionThresholds();
const level = getUserEffectiveLevel(userId);
for (const [perm, minLevel] of Object.entries(thresholds)) {
if (level >= minLevel && !perms.includes(perm)) perms.push(perm);
}
return perms;
}
function getUserRoles(userId) {
return db.prepare(`
SELECT r.id, r.name, r.level, r.scope, r.color, ur.channel_id
FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = ?
GROUP BY r.id, COALESCE(ur.channel_id, -1)
ORDER BY r.level DESC
`).all(userId);
}
function getUserHighestRole(userId, channelId = null) {
const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(userId);
if (user && user.is_admin) return { name: 'Admin', level: 100, color: '#e74c3c', icon: null };
let role = db.prepare(`
SELECT r.name, COALESCE(ur.custom_level, r.level) as level, r.color, r.icon FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = ? AND ur.channel_id IS NULL
ORDER BY COALESCE(ur.custom_level, r.level) DESC LIMIT 1
`).get(userId);
if (channelId) {
const chain = getChannelRoleChain(channelId);
if (chain.length > 0) {
const placeholders = chain.map(() => '?').join(',');
const chRole = db.prepare(`
SELECT r.name, COALESCE(ur.custom_level, r.level) as level, r.color, r.icon FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = ? AND ur.channel_id IN (${placeholders})
ORDER BY COALESCE(ur.custom_level, r.level) DESC LIMIT 1
`).get(userId, ...chain);
if (chRole && (!role || chRole.level > role.level)) role = chRole;
}
}
return role || null;
}
return {
getChannelRoleChain, getUserEffectiveLevel, getPermissionThresholds,
userHasPermission, getUserPermissions, getUserRoles, getUserHighestRole
};
};

747
src/socketHandlers/roles.js Normal file
View file

@ -0,0 +1,747 @@
'use strict';
const bcrypt = require('bcryptjs');
const { isString, isInt, VALID_ROLE_PERMS } = require('./helpers');
module.exports = function register(socket, ctx) {
const {
io, db, state, userHasPermission, getUserEffectiveLevel,
getUserPermissions, getUserRoles, getUserHighestRole,
emitOnlineUsers, broadcastChannelLists, getEnrichedChannels,
transferAdminRef, HAVEN_VERSION
} = ctx;
const { channelUsers } = state;
// ── Helper: apply role-linked channel access ────────────
function applyRoleChannelAccess(roleId, userId, direction) {
const role = db.prepare('SELECT link_channel_access FROM roles WHERE id = ?').get(roleId);
if (!role || !role.link_channel_access) return;
const col = direction === 'grant' ? 'grant_on_promote' : 'revoke_on_demote';
const channelRows = db.prepare(
`SELECT channel_id FROM role_channel_access WHERE role_id = ? AND ${col} = 1`
).all(roleId);
if (direction === 'grant') {
const ins = db.prepare('INSERT OR IGNORE INTO channel_members (channel_id, user_id) VALUES (?, ?)');
channelRows.forEach(r => ins.run(r.channel_id, userId));
} else {
const del = db.prepare('DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?');
channelRows.forEach(r => del.run(r.channel_id, userId));
}
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === userId) {
s.emit('channels-list', getEnrichedChannels(userId, s.user.isAdmin, (room) => s.join(room)));
}
}
}
// Expose on ctx so other modules can use it if needed
ctx.applyRoleChannelAccess = applyRoleChannelAccess;
// ── Notify helper: refresh a user's role state on all sockets ──
function refreshUserRoles(userId) {
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === userId) {
s.user.roles = getUserRoles(userId);
s.user.effectiveLevel = getUserEffectiveLevel(userId);
s.emit('roles-updated', {
roles: s.user.roles,
effectiveLevel: s.user.effectiveLevel,
permissions: getUserPermissions(userId)
});
}
}
for (const [code] of channelUsers) { emitOnlineUsers(code); }
}
// ── Get roles ───────────────────────────────────────────
socket.on('get-roles', (data, callback) => {
const roles = db.prepare('SELECT * FROM roles ORDER BY level DESC').all();
const permissions = db.prepare('SELECT * FROM role_permissions').all();
const permMap = {};
permissions.forEach(p => {
if (!permMap[p.role_id]) permMap[p.role_id] = [];
permMap[p.role_id].push(p.permission);
});
roles.forEach(r => { r.permissions = permMap[r.id] || []; });
if (typeof callback === 'function') callback({ roles });
else if (typeof data === 'function') data({ roles });
else socket.emit('roles-list', roles);
});
socket.on('get-user-roles', (data) => {
if (!data || typeof data !== 'object') return;
const userId = isInt(data.userId) ? data.userId : null;
if (!userId) return;
const roles = getUserRoles(userId);
const highestRole = getUserHighestRole(userId);
socket.emit('user-roles', { userId, roles, highestRole });
});
// ── Get channel member roles ────────────────────────────
socket.on('get-channel-member-roles', (data, callback) => {
if (!data || typeof data !== 'object') return;
const cb = typeof callback === 'function' ? callback : () => {};
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_roles')) {
return cb({ error: 'Only admins can view channel roles' });
}
const code = typeof data.code === 'string' ? data.code.trim() : '';
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return cb({ error: 'Invalid channel' });
const channel = db.prepare('SELECT id, name FROM channels WHERE code = ?').get(code);
if (!channel) return cb({ error: 'Channel not found' });
const members = db.prepare(`
SELECT u.id, COALESCE(u.display_name, u.username) as displayName,
u.username as loginName, u.avatar, u.avatar_shape, u.is_admin
FROM users u
JOIN channel_members cm ON u.id = cm.user_id
WHERE cm.channel_id = ?
ORDER BY COALESCE(u.display_name, u.username)
`).all(channel.id);
const memberIds = members.map(m => m.id);
const userRolesMap = {};
if (memberIds.length > 0) {
const placeholders = memberIds.map(() => '?').join(',');
const roleRows = db.prepare(`
SELECT ur.user_id, r.id as role_id, r.name, r.level, r.color, r.icon, ur.channel_id
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id IN (${placeholders})
AND (ur.channel_id IS NULL OR ur.channel_id = ?)
ORDER BY r.level DESC
`).all(...memberIds, channel.id);
roleRows.forEach(row => {
if (!userRolesMap[row.user_id]) userRolesMap[row.user_id] = [];
userRolesMap[row.user_id].push({
roleId: row.role_id, name: row.name, level: row.level,
color: row.color, icon: row.icon, scope: row.channel_id ? 'channel' : 'server'
});
});
}
const result = members.map(m => ({
id: m.id, displayName: m.displayName, loginName: m.loginName,
avatar: m.avatar, avatarShape: m.avatar_shape || 'circle',
isAdmin: !!m.is_admin, roles: userRolesMap[m.id] || []
}));
cb({ channelId: channel.id, channelName: channel.name, members: result });
});
// ── Create role ─────────────────────────────────────────
socket.on('create-role', (data, callback) => {
if (!data || typeof data !== 'object') return;
const cb = typeof callback === 'function' ? callback : () => {};
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_roles')) {
return cb({ error: 'Only admins can create roles' });
}
const name = isString(data.name, 1, 30) ? data.name.trim() : '';
if (!name) return cb({ error: 'Role name required (1-30 chars)' });
const level = isInt(data.level) && data.level >= 1 && data.level <= 99 ? data.level : 25;
const scope = data.scope === 'channel' ? 'channel' : 'server';
const color = isString(data.color, 4, 7) && /^#[0-9a-fA-F]{3,6}$/.test(data.color) ? data.color : null;
const autoAssign = data.autoAssign ? 1 : 0;
const icon = isString(data.icon, 1, 512) && /^\/uploads\//i.test(data.icon) ? data.icon : null;
try {
if (autoAssign) {
db.prepare('UPDATE roles SET auto_assign = 0').run();
}
const result = db.prepare(
'INSERT INTO roles (name, level, scope, color, auto_assign, icon) VALUES (?, ?, ?, ?, ?, ?)'
).run(name, level, scope, color, autoAssign, icon);
const perms = Array.isArray(data.permissions) ? data.permissions : [];
const adminOnlyPerms = ['transfer_admin', 'manage_roles', 'manage_server', 'delete_channel'];
const insertPerm = db.prepare('INSERT OR IGNORE INTO role_permissions (role_id, permission, allowed) VALUES (?, ?, 1)');
perms.forEach(p => {
if (!VALID_ROLE_PERMS.includes(p)) return;
if (!socket.user.isAdmin && (adminOnlyPerms.includes(p) || !userHasPermission(socket.user.id, p))) return;
insertPerm.run(result.lastInsertRowid, p);
});
cb({ success: true, roleId: result.lastInsertRowid });
} catch (err) {
console.error('Create role error:', err);
cb({ error: 'Failed to create role' });
}
});
// ── Update role ─────────────────────────────────────────
socket.on('update-role', (data, callback) => {
if (!data || typeof data !== 'object') return;
const cb = typeof callback === 'function' ? callback : () => {};
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_roles')) {
return cb({ error: 'Only admins can edit roles' });
}
const roleId = isInt(data.roleId) ? data.roleId : null;
if (!roleId) return;
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(roleId);
if (!role) return cb({ error: 'Role not found' });
const updateRoleTx = db.transaction(() => {
const updates = [];
const values = [];
if (isString(data.name, 1, 30)) { updates.push('name = ?'); values.push(data.name.trim()); }
if (isInt(data.level) && data.level >= 1 && data.level <= 99) { updates.push('level = ?'); values.push(data.level); }
if (data.color !== undefined) {
const safeColor = (isString(data.color, 4, 7) && /^#[0-9a-fA-F]{3,6}$/.test(data.color)) ? data.color : null;
updates.push('color = ?'); values.push(safeColor);
}
if (data.icon !== undefined) {
const safeIcon = (isString(data.icon, 1, 512) && /^\/uploads\//i.test(data.icon)) ? data.icon : null;
updates.push('icon = ?'); values.push(safeIcon);
}
if (data.autoAssign !== undefined) {
if (data.autoAssign) {
db.prepare('UPDATE roles SET auto_assign = 0').run();
}
updates.push('auto_assign = ?'); values.push(data.autoAssign ? 1 : 0);
}
if (data.linkChannelAccess !== undefined) {
updates.push('link_channel_access = ?'); values.push(data.linkChannelAccess ? 1 : 0);
}
if (updates.length > 0) {
values.push(roleId);
db.prepare(`UPDATE roles SET ${updates.join(', ')} WHERE id = ?`).run(...values);
}
if (Array.isArray(data.permissions)) {
const adminOnlyPerms = ['transfer_admin', 'manage_roles', 'manage_server', 'delete_channel'];
db.prepare('DELETE FROM role_permissions WHERE role_id = ?').run(roleId);
const insertPerm = db.prepare('INSERT INTO role_permissions (role_id, permission, allowed) VALUES (?, ?, 1)');
data.permissions.forEach(p => {
if (!VALID_ROLE_PERMS.includes(p)) return;
if (!socket.user.isAdmin && (adminOnlyPerms.includes(p) || !userHasPermission(socket.user.id, p))) return;
insertPerm.run(roleId, p);
});
}
});
updateRoleTx();
const freshRoles = db.prepare('SELECT * FROM roles ORDER BY level DESC').all();
const perms = db.prepare('SELECT * FROM role_permissions').all();
const pm = {};
perms.forEach(p => { if (!pm[p.role_id]) pm[p.role_id] = []; pm[p.role_id].push(p.permission); });
freshRoles.forEach(r => { r.permissions = pm[r.id] || []; });
for (const [code] of channelUsers) { emitOnlineUsers(code); }
socket.broadcast.emit('roles-updated');
cb({ success: true, roles: freshRoles });
});
// ── Delete role ─────────────────────────────────────────
socket.on('delete-role', (data, callback) => {
if (!data || typeof data !== 'object') return;
const cb = typeof callback === 'function' ? callback : () => {};
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_roles')) {
return cb({ error: 'Only admins can delete roles' });
}
const roleId = isInt(data.roleId) ? data.roleId : null;
if (!roleId) return;
db.prepare('DELETE FROM user_roles WHERE role_id = ?').run(roleId);
db.prepare('DELETE FROM role_permissions WHERE role_id = ?').run(roleId);
db.prepare('DELETE FROM role_channel_access WHERE role_id = ?').run(roleId);
db.prepare('DELETE FROM roles WHERE id = ?').run(roleId);
for (const [code] of channelUsers) { emitOnlineUsers(code); }
cb({ success: true });
});
// ── Reset roles to default ─────────────────────────────
socket.on('reset-roles-to-default', (data, callback) => {
const cb = typeof callback === 'function' ? callback : () => {};
if (!socket.user.isAdmin) return cb({ error: 'Only admins can reset roles' });
try {
db.exec('DELETE FROM user_roles');
db.exec('DELETE FROM role_permissions');
db.exec('DELETE FROM role_channel_access');
db.exec('DELETE FROM roles');
const insertRole = db.prepare('INSERT INTO roles (name, level, scope, color) VALUES (?, ?, ?, ?)');
const insertPerm = db.prepare('INSERT INTO role_permissions (role_id, permission, allowed) VALUES (?, ?, 1)');
const serverMod = insertRole.run('Server Mod', 50, 'server', '#3498db');
['kick_user','mute_user','delete_message','pin_message','set_channel_topic','manage_sub_channels','rename_channel','rename_sub_channel','delete_lower_messages','manage_webhooks','upload_files','use_voice','view_history','view_all_members','manage_music_queue','delete_own_messages','edit_own_messages']
.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','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');
db.prepare('UPDATE roles SET auto_assign = 1 WHERE id = ?').run(userRole.lastInsertRowid);
['delete_own_messages','edit_own_messages','upload_files','use_voice','view_history']
.forEach(p => insertPerm.run(userRole.lastInsertRowid, p));
const autoRoles = db.prepare('SELECT id FROM roles WHERE auto_assign = 1 AND scope = ?').all('server');
for (const ar of autoRoles) {
db.prepare(`
INSERT OR IGNORE INTO user_roles (user_id, role_id, channel_id, granted_by)
SELECT u.id, ?, NULL, NULL FROM users u
`).run(ar.id);
}
for (const [code] of channelUsers) { emitOnlineUsers(code); }
io.emit('roles-updated');
cb({ success: true });
} catch (err) {
cb({ error: 'Failed to reset roles: ' + err.message });
}
});
// ── Get role assignment data (three-pane) ───────────────
socket.on('get-role-assignment-data', (data, callback) => {
if (!data || typeof data !== 'object') return;
const cb = typeof callback === 'function' ? callback : () => {};
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'promote_user') && !userHasPermission(socket.user.id, 'manage_roles')) {
return cb({ error: 'You lack permission to manage roles' });
}
try {
const callerId = socket.user.id;
const callerIsAdmin = socket.user.isAdmin;
const callerServerLevel = getUserEffectiveLevel(callerId);
const callerChannels = db.prepare(`
SELECT c.id, c.name, c.code, c.parent_channel_id, c.position
FROM channels c
JOIN channel_members cm ON c.id = cm.channel_id
WHERE cm.user_id = ? AND c.is_dm = 0
ORDER BY c.position, c.name
`).all(callerId);
if (callerChannels.length === 0) {
const roles = db.prepare('SELECT * FROM roles ORDER BY level DESC').all();
const permissions = db.prepare('SELECT * FROM role_permissions').all();
const permMap = {};
permissions.forEach(p => { if (!permMap[p.role_id]) permMap[p.role_id] = []; permMap[p.role_id].push(p.permission); });
roles.forEach(r => { r.permissions = permMap[r.id] || []; });
return cb({ users: [], userChannelMap: {}, channels: [], roles, callerPerms: getUserPermissions(callerId), callerLevel: callerServerLevel, callerIsAdmin });
}
const allMembers = db.prepare(`
SELECT DISTINCT u.id, u.username, COALESCE(u.display_name, u.username) as displayName,
u.avatar, u.avatar_shape, u.is_admin
FROM users u
JOIN channel_members cm ON u.id = cm.user_id
WHERE cm.channel_id IN (${callerChannels.map(() => '?').join(',')})
AND u.id != ?
ORDER BY COALESCE(u.display_name, u.username)
`).all(...callerChannels.map(c => c.id), callerId);
const users = [];
const userChannelMap = {};
for (const m of allMembers) {
if (m.is_admin) continue;
const userServerLevel = getUserEffectiveLevel(m.id);
if (!callerIsAdmin && userServerLevel >= callerServerLevel) continue;
const uChans = db.prepare(`
SELECT cm.channel_id FROM channel_members cm
WHERE cm.user_id = ? AND cm.channel_id IN (${callerChannels.map(() => '?').join(',')})
`).all(m.id, ...callerChannels.map(c => c.id));
const sharedChannels = [];
for (const uc of uChans) {
const callerChanLevel = getUserEffectiveLevel(callerId, uc.channel_id);
const userChanLevel = getUserEffectiveLevel(m.id, uc.channel_id);
if (callerIsAdmin || callerChanLevel > userChanLevel) {
sharedChannels.push(uc.channel_id);
}
}
if (sharedChannels.length === 0 && !callerIsAdmin) continue;
const currentRoles = db.prepare(`
SELECT ur.role_id, ur.channel_id, r.name, r.level, r.color
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({
id: m.id, username: m.username, displayName: m.displayName,
avatar: m.avatar || null, avatarShape: m.avatar_shape || 'circle',
serverLevel: userServerLevel, currentRoles
});
userChannelMap[m.id] = sharedChannels;
}
const channelsWithHierarchy = callerChannels.map(c => ({
id: c.id, name: c.name, code: c.code,
parentId: c.parent_channel_id, position: c.position
}));
const roles = db.prepare('SELECT * FROM roles ORDER BY level DESC').all();
const permissions = db.prepare('SELECT * FROM role_permissions').all();
const permMap = {};
permissions.forEach(p => { if (!permMap[p.role_id]) permMap[p.role_id] = []; permMap[p.role_id].push(p.permission); });
roles.forEach(r => { r.permissions = permMap[r.id] || []; });
const callerPerms = getUserPermissions(callerId);
cb({
users, userChannelMap, channels: channelsWithHierarchy,
roles, callerPerms, callerLevel: callerServerLevel, callerIsAdmin
});
} catch (err) {
console.error('get-role-assignment-data error:', err);
cb({ error: 'Failed to load role assignment data' });
}
});
// ── Assign role ─────────────────────────────────────────
socket.on('assign-role', (data, callback) => {
const cb = typeof callback === 'function' ? callback : () => {};
if (!data || typeof data !== 'object') return cb({ error: 'Invalid request' });
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'promote_user')) {
return cb({ error: 'You lack permission to assign roles' });
}
const userId = isInt(data.userId) ? data.userId : null;
const roleId = isInt(data.roleId) ? data.roleId : null;
if (!userId || !roleId) return cb({ error: 'Missing userId or roleId' });
if (userId === socket.user.id) {
return cb({ error: 'You cannot modify your own roles' });
}
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(roleId);
if (!role) return cb({ error: 'Role not found' });
if (!socket.user.isAdmin) {
const myLevel = getUserEffectiveLevel(socket.user.id);
if (role.level >= myLevel) {
return cb({ error: `You can only assign roles below your level (${myLevel})` });
}
}
const channelId = isInt(data.channelId) ? data.channelId : null;
let assignLevel = role.level;
if (data.customLevel !== undefined && data.customLevel !== null) {
const cl = parseInt(data.customLevel);
if (!isNaN(cl) && cl >= 1 && cl <= 99) {
if (!socket.user.isAdmin) {
const myLevel = getUserEffectiveLevel(socket.user.id);
if (cl >= myLevel) {
return cb({ error: `Custom level must be below your level (${myLevel})` });
}
}
assignLevel = cl;
}
}
try {
if (channelId) {
db.prepare('DELETE FROM user_roles WHERE user_id = ? AND channel_id = ?').run(userId, channelId);
} else {
db.prepare(
`DELETE FROM user_roles WHERE user_id = ? AND channel_id IS NULL
AND role_id IN (SELECT id FROM roles WHERE scope = ?)`
).run(userId, role.scope);
}
db.prepare(
'INSERT INTO user_roles (user_id, role_id, channel_id, granted_by, custom_level) VALUES (?, ?, ?, ?, ?)'
).run(userId, roleId, channelId, socket.user.id, assignLevel !== role.level ? assignLevel : null);
if (data.customPerms && Array.isArray(data.customPerms)) {
if (channelId) {
db.prepare('DELETE FROM user_role_perms WHERE user_id = ? AND role_id = ? AND channel_id = ?').run(userId, roleId, channelId);
} else {
db.prepare('DELETE FROM user_role_perms WHERE user_id = ? AND role_id = ? AND channel_id IS NULL').run(userId, roleId);
}
const rolePerms = db.prepare('SELECT permission FROM role_permissions WHERE role_id = ? AND allowed = 1').all(roleId).map(r => r.permission);
const customPerms = data.customPerms.filter(p => typeof p === 'string');
const added = customPerms.filter(p => !rolePerms.includes(p));
const removed = rolePerms.filter(p => !customPerms.includes(p));
if (added.length > 0 || removed.length > 0) {
const insertStmt = db.prepare('INSERT INTO user_role_perms (user_id, role_id, channel_id, permission, allowed) VALUES (?, ?, ?, ?, ?)');
for (const p of added) insertStmt.run(userId, roleId, channelId, p, 1);
for (const p of removed) insertStmt.run(userId, roleId, channelId, p, 0);
}
}
applyRoleChannelAccess(roleId, userId, 'grant');
refreshUserRoles(userId);
cb({ success: true });
} catch (err) {
console.error('Assign role error:', err);
cb({ error: 'Failed to assign role' });
}
});
// ── Revoke role ─────────────────────────────────────────
socket.on('revoke-role', (data, callback) => {
const cb = typeof callback === 'function' ? callback : () => {};
if (!data || typeof data !== 'object') return cb({ error: 'Invalid request' });
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'promote_user')) {
return cb({ error: 'You lack permission to revoke roles' });
}
const userId = isInt(data.userId) ? data.userId : null;
const roleId = isInt(data.roleId) ? data.roleId : null;
if (!userId || !roleId) return cb({ error: 'Missing userId or roleId' });
if (userId === socket.user.id) {
return cb({ error: 'You cannot modify your own roles' });
}
if (!socket.user.isAdmin) {
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(roleId);
if (role) {
const myLevel = getUserEffectiveLevel(socket.user.id);
if (role.level >= myLevel) {
return cb({ error: `You can only revoke roles below your level (${myLevel})` });
}
}
}
const channelId = isInt(data.channelId) ? data.channelId : null;
applyRoleChannelAccess(roleId, userId, 'revoke');
if (channelId) {
db.prepare('DELETE FROM user_roles WHERE user_id = ? AND role_id = ? AND channel_id = ?').run(userId, roleId, channelId);
} else {
db.prepare('DELETE FROM user_roles WHERE user_id = ? AND role_id = ? AND channel_id IS NULL').run(userId, roleId);
}
const target = db.prepare('SELECT COALESCE(display_name, username) as username FROM users WHERE id = ?').get(userId);
cb({ success: true, message: `Revoked role from ${target ? target.username : 'user'}` });
refreshUserRoles(userId);
});
// ── Role channel access ─────────────────────────────────
socket.on('get-role-channel-access', (data, callback) => {
if (!data || typeof data !== 'object') return;
const cb = typeof callback === 'function' ? callback : () => {};
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_roles')) {
return cb({ error: 'Only admins can view role channel access' });
}
const roleId = isInt(data.roleId) ? data.roleId : null;
if (!roleId) return cb({ error: 'Invalid role ID' });
const rows = db.prepare('SELECT channel_id, grant_on_promote, revoke_on_demote FROM role_channel_access WHERE role_id = ?').all(roleId);
const channels = db.prepare('SELECT id, name, parent_channel_id, is_dm, is_private, position FROM channels WHERE is_dm = 0 ORDER BY parent_channel_id IS NOT NULL, position, name').all();
cb({ success: true, access: rows, channels });
});
socket.on('update-role-channel-access', (data, callback) => {
if (!data || typeof data !== 'object') return;
const cb = typeof callback === 'function' ? callback : () => {};
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_roles')) {
return cb({ error: 'Only admins can edit role channel access' });
}
const roleId = isInt(data.roleId) ? data.roleId : null;
if (!roleId) return cb({ error: 'Invalid role ID' });
if (!Array.isArray(data.access)) return cb({ error: 'Invalid access data' });
try {
const txn = db.transaction(() => {
db.prepare('DELETE FROM role_channel_access WHERE role_id = ?').run(roleId);
const ins = db.prepare('INSERT INTO role_channel_access (role_id, channel_id, grant_on_promote, revoke_on_demote) VALUES (?, ?, ?, ?)');
data.access.forEach(a => {
const chId = isInt(a.channelId) ? a.channelId : null;
if (!chId) return;
const grant = a.grant ? 1 : 0;
const revoke = a.revoke ? 1 : 0;
if (grant || revoke) ins.run(roleId, chId, grant, revoke);
});
if (data.linkEnabled !== undefined) {
db.prepare('UPDATE roles SET link_channel_access = ? WHERE id = ?').run(data.linkEnabled ? 1 : 0, roleId);
}
});
txn();
cb({ success: true });
} catch (err) {
console.error('Update role channel access error:', err);
cb({ error: 'Failed to update channel access' });
}
});
socket.on('reapply-role-access', (data, callback) => {
if (!data || typeof data !== 'object') return;
const cb = typeof callback === 'function' ? callback : () => {};
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'manage_roles')) {
return cb({ error: 'Only admins can reapply access' });
}
const roleId = isInt(data.roleId) ? data.roleId : null;
if (!roleId) return cb({ error: 'Invalid role ID' });
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(roleId);
if (!role) return cb({ error: 'Role not found' });
if (!role.link_channel_access) return cb({ error: 'Channel access linking is not enabled for this role' });
const roleUsers = db.prepare('SELECT DISTINCT user_id FROM user_roles WHERE role_id = ?').all(roleId);
const grantChannels = db.prepare('SELECT channel_id FROM role_channel_access WHERE role_id = ? AND grant_on_promote = 1').all(roleId);
const ins = db.prepare('INSERT OR IGNORE INTO channel_members (channel_id, user_id) VALUES (?, ?)');
const txn = db.transaction(() => {
roleUsers.forEach(u => {
grantChannels.forEach(c => ins.run(c.channel_id, u.user_id));
});
});
txn();
broadcastChannelLists();
cb({ success: true, affected: roleUsers.length });
});
// ── Promote user ────────────────────────────────────────
socket.on('promote-user', (data, callback) => {
if (!data || typeof data !== 'object') return;
const cb = typeof callback === 'function' ? callback : () => {};
const userId = isInt(data.userId) ? data.userId : null;
const roleId = isInt(data.roleId) ? data.roleId : null;
if (!userId || !roleId) return cb({ error: 'Invalid parameters' });
if (userId === socket.user.id) return cb({ error: 'Cannot promote yourself' });
const myLevel = getUserEffectiveLevel(socket.user.id);
const hasPromotePerm = socket.user.isAdmin || userHasPermission(socket.user.id, 'promote_user');
if (!hasPromotePerm) return cb({ error: 'You lack the promote_user permission' });
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(roleId);
if (!role) return cb({ error: 'Role not found' });
if (role.level >= myLevel) {
return cb({ error: `You can only assign roles below your level (${myLevel})` });
}
const channelId = isInt(data.channelId) ? data.channelId : null;
try {
if (channelId) {
db.prepare('DELETE FROM user_roles WHERE user_id = ? AND role_id = ? AND channel_id = ?').run(userId, roleId, channelId);
} else {
db.prepare('DELETE FROM user_roles WHERE user_id = ? AND role_id = ? AND channel_id IS NULL').run(userId, roleId);
}
db.prepare(
'INSERT INTO user_roles (user_id, role_id, channel_id, granted_by) VALUES (?, ?, ?, ?)'
).run(userId, roleId, channelId, socket.user.id);
refreshUserRoles(userId);
cb({ success: true });
} catch (err) {
console.error('Promote user error:', err);
cb({ error: 'Failed to promote user' });
}
});
// ── Transfer admin ──────────────────────────────────────
socket.on('transfer-admin', async (data, callback) => {
if (!data || typeof data !== 'object') return;
const cb = typeof callback === 'function' ? callback : () => {};
if (!socket.user.isAdmin) return cb({ error: 'Only admins can transfer admin' });
if (transferAdminRef.value) return cb({ error: 'A transfer is already in progress' });
transferAdminRef.value = true;
try {
const password = typeof data.password === 'string' ? data.password : '';
if (!password) { transferAdminRef.value = false; return cb({ error: 'Password is required for this action' }); }
const adminUser = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(socket.user.id);
if (!adminUser) { transferAdminRef.value = false; return cb({ error: 'Admin user not found' }); }
let validPw;
try {
validPw = await bcrypt.compare(password, adminUser.password_hash);
if (!validPw) { transferAdminRef.value = false; return cb({ error: 'Incorrect password' }); }
} catch (err) {
console.error('Password verification error:', err);
transferAdminRef.value = false;
return cb({ error: 'Password verification failed' });
}
const stillAdmin = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(socket.user.id);
if (!stillAdmin || !stillAdmin.is_admin) { transferAdminRef.value = false; return cb({ error: 'You are no longer an admin' }); }
const userId = isInt(data.userId) ? data.userId : null;
if (!userId) return cb({ error: 'Invalid user' });
if (userId === socket.user.id) return cb({ error: 'Cannot transfer to yourself' });
const targetUser = db.prepare('SELECT id, username, is_admin FROM users WHERE id = ?').get(userId);
if (!targetUser) return cb({ error: 'User not found' });
if (targetUser.is_admin) return cb({ error: 'User is already an admin' });
try {
const transferTxn = db.transaction(() => {
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId);
db.prepare('UPDATE users SET is_admin = 0 WHERE id = ?').run(socket.user.id);
let formerAdminRole = db.prepare("SELECT id FROM roles WHERE name = 'Former Admin' AND level = 99").get();
if (!formerAdminRole) {
const r = db.prepare("INSERT INTO roles (name, level, scope, color) VALUES ('Former Admin', 99, 'server', '#e74c3c')").run();
formerAdminRole = { id: r.lastInsertRowid };
const allPerms = [...VALID_ROLE_PERMS];
const insertPerm = db.prepare('INSERT OR IGNORE INTO role_permissions (role_id, permission, allowed) VALUES (?, ?, 1)');
allPerms.forEach(p => insertPerm.run(formerAdminRole.id, p));
}
db.prepare('DELETE FROM user_roles WHERE user_id = ? AND role_id = ? AND channel_id IS NULL').run(socket.user.id, formerAdminRole.id);
db.prepare('INSERT INTO user_roles (user_id, role_id, channel_id, granted_by) VALUES (?, ?, NULL, ?)').run(
socket.user.id, formerAdminRole.id, socket.user.id
);
});
transferTxn();
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === userId) {
s.user.isAdmin = true;
s.user.roles = getUserRoles(userId);
s.user.effectiveLevel = 100;
s.emit('session-info', {
id: s.user.id, username: s.user.username, isAdmin: true,
displayName: s.user.displayName, avatar: s.user.avatar || null,
avatarShape: s.user.avatar_shape || 'circle',
version: HAVEN_VERSION, roles: s.user.roles,
effectiveLevel: 100, permissions: ['*'],
status: s.user.status || 'online',
statusText: s.user.statusText || ''
});
}
if (s.user && s.user.id === socket.user.id) {
s.user.isAdmin = false;
s.user.roles = getUserRoles(socket.user.id);
s.user.effectiveLevel = getUserEffectiveLevel(socket.user.id);
s.emit('session-info', {
id: s.user.id, username: s.user.username, isAdmin: false,
displayName: s.user.displayName, avatar: s.user.avatar || null,
avatarShape: s.user.avatar_shape || 'circle',
version: HAVEN_VERSION, roles: s.user.roles,
effectiveLevel: s.user.effectiveLevel,
permissions: getUserPermissions(socket.user.id),
status: s.user.status || 'online',
statusText: s.user.statusText || ''
});
}
}
for (const [code] of channelUsers) { emitOnlineUsers(code); }
cb({ success: true, message: `Admin transferred to ${targetUser.username}` });
} catch (err) {
console.error('Transfer admin error:', err);
cb({ error: 'Failed to transfer admin' });
}
} finally {
transferAdminRef.value = false;
}
});
};

524
src/socketHandlers/users.js Normal file
View file

@ -0,0 +1,524 @@
'use strict';
const path = require('path');
const fs = require('fs');
const { utcStamp, isString, isInt, sanitizeText, isValidUploadPath } = require('./helpers');
module.exports = function register(socket, ctx) {
const { io, db, state, getChannelRoleChain, userHasPermission,
emitOnlineUsers, broadcastVoiceUsers, generateToken,
touchVoiceActivity, DATA_DIR } = ctx;
const { channelUsers, voiceUsers } = state;
// ── Rename (display name) ───────────────────────────────
socket.on('rename-user', (data) => {
if (!data || typeof data !== 'object') return;
const newName = typeof data.username === 'string' ? data.username.trim().replace(/\s+/g, ' ') : '';
if (!newName || newName.length < 2 || newName.length > 20) {
return socket.emit('error-msg', 'Display name must be 2-20 characters');
}
if (!/^[a-zA-Z0-9_ ]+$/.test(newName)) {
return socket.emit('error-msg', 'Letters, numbers, underscores, and spaces only');
}
try {
db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(newName, socket.user.id);
} catch (err) {
console.error('Rename error:', err);
return socket.emit('error-msg', 'Failed to update display name');
}
const oldName = socket.user.displayName;
socket.user.displayName = newName;
const newToken = generateToken({
id: socket.user.id,
username: socket.user.username,
isAdmin: socket.user.isAdmin,
displayName: newName
});
for (const [code, users] of channelUsers) {
if (users.has(socket.user.id)) {
users.get(socket.user.id).username = newName;
emitOnlineUsers(code);
}
}
for (const [code, users] of voiceUsers) {
if (users.has(socket.user.id)) {
users.get(socket.user.id).username = newName;
broadcastVoiceUsers(code);
}
}
socket.emit('renamed', {
token: newToken,
user: { id: socket.user.id, username: socket.user.username, isAdmin: socket.user.isAdmin, displayName: newName },
oldName
});
if (socket.currentChannel) {
socket.to(`channel:${socket.currentChannel}`).emit('user-renamed', {
channelCode: socket.currentChannel,
oldName,
newName
});
}
// Notify all DM partners so their sidebar updates the display name
try {
const dmPartners = db.prepare(`
SELECT DISTINCT cm2.user_id FROM channel_members cm1
JOIN channels c ON c.id = cm1.channel_id AND c.is_dm = 1
JOIN channel_members cm2 ON cm2.channel_id = c.id AND cm2.user_id != ?
WHERE cm1.user_id = ?
`).all(socket.user.id, socket.user.id);
for (const partner of dmPartners) {
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === partner.user_id) {
s.emit('dm-name-updated', { userId: socket.user.id, newName });
}
}
}
} catch (err) {
console.error('DM name update broadcast error:', err);
}
console.log(`✏️ ${oldName} renamed to ${newName}`);
});
// ── Avatar ──────────────────────────────────────────────
socket.on('set-avatar', (data) => {
if (!data || typeof data !== 'object') return;
const url = typeof data.url === 'string' ? data.url.trim() : '';
if (url && !isValidUploadPath(url)) return;
socket.user.avatar = url || null;
console.log(`[Avatar] ${socket.user.username} broadcast avatar: ${url || '(removed)'}`);
for (const [code, users] of channelUsers) {
if (users.has(socket.user.id)) {
users.get(socket.user.id).avatar = url || null;
emitOnlineUsers(code);
}
}
});
socket.on('set-avatar-shape', (data) => {
if (!data || typeof data !== 'object') return;
const validShapes = ['circle', 'rounded', 'squircle', 'hex', 'diamond'];
const shape = validShapes.includes(data.shape) ? data.shape : 'circle';
try {
db.prepare('UPDATE users SET avatar_shape = ? WHERE id = ?').run(shape, socket.user.id);
socket.user.avatar_shape = shape;
console.log(`[Avatar] ${socket.user.username} set shape: ${shape}`);
for (const [code, users] of channelUsers) {
if (users.has(socket.user.id)) {
users.get(socket.user.id).avatar_shape = shape;
emitOnlineUsers(code);
}
}
socket.emit('avatar-shape-updated', { shape });
} catch (err) {
console.error('Set avatar shape error:', err);
}
});
// ── Status ──────────────────────────────────────────────
socket.on('set-status', (data) => {
if (!data || typeof data !== 'object') return;
const validStatuses = ['online', 'away', 'dnd', 'invisible'];
const status = validStatuses.includes(data.status) ? data.status : 'online';
const statusText = isString(data.statusText, 0, 128) ? data.statusText.trim() : '';
try {
db.prepare('UPDATE users SET status = ?, status_text = ? WHERE id = ?')
.run(status, statusText, socket.user.id);
} catch (err) {
console.error('Set status error:', err);
return;
}
socket.user.status = status;
socket.user.statusText = statusText;
for (const [code, users] of channelUsers) {
if (users.has(socket.user.id)) {
users.get(socket.user.id).status = status;
users.get(socket.user.id).statusText = statusText;
emitOnlineUsers(code);
}
}
socket.emit('status-updated', { status, statusText });
});
// ── Profile ─────────────────────────────────────────────
socket.on('get-user-profile', (data) => {
if (!data || typeof data.userId !== 'number') return;
try {
const row = db.prepare(
`SELECT u.id, u.username, COALESCE(u.display_name, u.username) as displayName,
u.avatar, u.avatar_shape, u.status, u.status_text, u.bio, u.created_at
FROM users u WHERE u.id = ?`
).get(data.userId);
if (!row) return;
const roles = db.prepare(
`SELECT DISTINCT r.id, r.name, r.level, r.color
FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = ? AND ur.channel_id IS NULL
GROUP BY r.id
ORDER BY r.level DESC`
).all(data.userId);
const currentChannelCode = socket.currentChannel;
if (currentChannelCode) {
const ch = db.prepare('SELECT id FROM channels WHERE code = ?').get(currentChannelCode);
if (ch) {
const chain = getChannelRoleChain(ch.id);
if (chain.length > 0) {
const placeholders = chain.map(() => '?').join(',');
const channelRoles = db.prepare(
`SELECT DISTINCT r.id, r.name, COALESCE(ur.custom_level, r.level) as level, r.color
FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = ? AND ur.channel_id IN (${placeholders})
GROUP BY r.id
ORDER BY r.level DESC`
).all(data.userId, ...chain);
const existingIds = new Set(roles.map(r => r.id));
for (const cr of channelRoles) {
if (!existingIds.has(cr.id)) {
roles.push(cr);
existingIds.add(cr.id);
}
}
roles.sort((a, b) => b.level - a.level);
}
}
}
const isAdmin = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(data.userId);
if (isAdmin && isAdmin.is_admin) {
roles.length = 0;
roles.push({ id: -1, name: 'Admin', level: 100, color: '#e74c3c' });
} else if (roles.length > 1) {
const userRoleIdx = roles.findIndex(r => r.name === 'User' && r.level <= 1);
if (userRoleIdx !== -1) roles.splice(userRoleIdx, 1);
}
let isOnline = false;
for (const [, s] of io.of('/').sockets) {
if (s.user && s.user.id === data.userId) { isOnline = true; break; }
}
socket.emit('user-profile', {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar || null,
avatarShape: row.avatar_shape || 'circle',
status: row.status || 'online',
statusText: row.status_text || '',
bio: row.bio || '',
roles: roles,
online: isOnline,
createdAt: row.created_at
});
} catch (err) {
console.error('Get user profile error:', err);
}
});
socket.on('set-bio', (data) => {
if (!data || typeof data.bio !== 'string') return;
const bio = sanitizeText(data.bio.trim().slice(0, 190));
try {
db.prepare('UPDATE users SET bio = ? WHERE id = ?').run(bio, socket.user.id);
socket.emit('bio-updated', { bio });
} catch (err) {
console.error('Set bio error:', err);
}
});
// ── Push Notifications ──────────────────────────────────
socket.on('push-subscribe', (data) => {
if (!data || typeof data !== 'object') return;
const { endpoint, keys } = data;
if (typeof endpoint !== 'string' || !endpoint) return;
if (!keys || typeof keys !== 'object') return;
if (typeof keys.p256dh !== 'string' || !keys.p256dh) return;
if (typeof keys.auth !== 'string' || !keys.auth) return;
try { const u = new URL(endpoint); if (u.protocol !== 'https:') return; } catch { return; }
try {
db.prepare(`
INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, endpoint) DO UPDATE SET p256dh = excluded.p256dh, auth = excluded.auth
`).run(socket.user.id, endpoint, keys.p256dh, keys.auth);
socket.emit('push-subscribed');
} catch (err) {
console.error('Push subscribe error:', err);
}
});
socket.on('push-unsubscribe', (data) => {
if (!data || typeof data !== 'object') return;
const endpoint = typeof data.endpoint === 'string' ? data.endpoint : '';
if (!endpoint) return;
try {
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?')
.run(socket.user.id, endpoint);
socket.emit('push-unsubscribed');
} catch (err) {
console.error('Push unsubscribe error:', err);
}
});
// ── FCM Tokens ──────────────────────────────────────────
socket.on('register-fcm-token', (data) => {
if (!data || typeof data.token !== 'string' || !data.token.trim()) return;
try {
db.prepare(`
INSERT INTO fcm_tokens (user_id, token)
VALUES (?, ?)
ON CONFLICT(user_id, token) DO NOTHING
`).run(socket.user.id, data.token.trim());
} catch (err) {
console.error('FCM token register error:', err);
}
});
socket.on('unregister-fcm-token', (data) => {
if (!data || typeof data.token !== 'string') return;
try {
db.prepare('DELETE FROM fcm_tokens WHERE user_id = ? AND token = ?')
.run(socket.user.id, data.token.trim());
} catch (err) {
console.error('FCM token unregister error:', err);
}
});
// ── E2E Public Key Exchange ─────────────────────────────
socket.on('publish-public-key', (data) => {
if (!data || typeof data !== 'object') return;
const jwk = data.jwk;
if (!jwk || typeof jwk !== 'object' || jwk.kty !== 'EC' || jwk.crv !== 'P-256') {
return socket.emit('error-msg', 'Invalid public key format');
}
const publicJwk = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
try {
const current = db.prepare('SELECT public_key FROM users WHERE id = ?').get(socket.user.id);
let keyChanged = false;
if (current && current.public_key && !data.force) {
const existing = JSON.parse(current.public_key);
if (existing.x !== publicJwk.x || existing.y !== publicJwk.y) {
console.warn(`[E2E] User ${socket.user.id} (${socket.user.username}) tried to overwrite public key — blocked`);
socket.emit('public-key-conflict', { existing });
return;
}
} else if (current && current.public_key) {
const existing = JSON.parse(current.public_key);
keyChanged = existing.x !== publicJwk.x || existing.y !== publicJwk.y;
}
db.prepare('UPDATE users SET public_key = ? WHERE id = ?')
.run(JSON.stringify(publicJwk), socket.user.id);
socket.emit('public-key-published');
if (keyChanged) {
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === socket.user.id && s !== socket) {
s.emit('e2e-key-sync');
}
}
const dmPartners = db.prepare(`
SELECT DISTINCT cm2.user_id FROM channel_members cm1
JOIN channels c ON c.id = cm1.channel_id AND c.is_dm = 1
JOIN channel_members cm2 ON cm2.channel_id = c.id AND cm2.user_id != ?
WHERE cm1.user_id = ?
`).all(socket.user.id, socket.user.id);
for (const partner of dmPartners) {
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === partner.user_id) {
s.emit('public-key-result', { userId: socket.user.id, jwk: publicJwk });
}
}
}
console.log(`[E2E] Notified ${dmPartners.length} DM partner(s) + other sessions of key change for user ${socket.user.id}`);
}
} catch (err) {
console.error('Publish public key error:', err);
socket.emit('error-msg', 'Failed to store public key');
}
});
socket.on('get-public-key', (data) => {
if (!data || typeof data !== 'object') return;
const userId = typeof data.userId === 'number' ? data.userId : parseInt(data.userId);
if (!userId || isNaN(userId)) return;
const row = db.prepare('SELECT public_key FROM users WHERE id = ?').get(userId);
const jwk = row && row.public_key ? JSON.parse(row.public_key) : null;
socket.emit('public-key-result', { userId, jwk });
});
// ── E2E Encrypted Private Key Storage ───────────────────
socket.on('store-encrypted-key', (data) => {
if (!data || typeof data !== 'object') return;
const { encryptedKey, salt } = data;
if (typeof encryptedKey !== 'string' || typeof salt !== 'string') {
return socket.emit('error-msg', 'Invalid encrypted key data');
}
if (encryptedKey.length > 4096 || salt.length > 128) {
return socket.emit('error-msg', 'Encrypted key data too large');
}
try {
db.prepare('UPDATE users SET encrypted_private_key = ?, e2e_key_salt = ? WHERE id = ?')
.run(encryptedKey, salt, socket.user.id);
socket.emit('encrypted-key-stored');
} catch (err) {
console.error('Store encrypted key error:', err);
socket.emit('error-msg', 'Failed to store encrypted key');
}
});
socket.on('get-encrypted-key', () => {
try {
const row = db.prepare('SELECT encrypted_private_key, e2e_key_salt, public_key FROM users WHERE id = ?')
.get(socket.user.id);
const hasBackup = !!(row && row.encrypted_private_key && row.e2e_key_salt);
// Forward just the pub-key JWK (x,y) so clients can detect
// local-vs-server divergence without an extra round-trip. Additive:
// legacy clients ignore it.
let publicKey = null;
if (row && row.public_key) {
try {
const parsed = typeof row.public_key === 'string' ? JSON.parse(row.public_key) : row.public_key;
if (parsed && parsed.x && parsed.y) publicKey = { kty: parsed.kty, crv: parsed.crv, x: parsed.x, y: parsed.y };
} catch { /* stored pub key not JSON — skip */ }
}
socket.emit('encrypted-key-result', {
encryptedKey: row?.encrypted_private_key || null,
salt: row?.e2e_key_salt || null,
hasPublicKey: !!(row && row.public_key),
publicKey,
state: hasBackup ? 'present' : 'empty'
});
} catch (err) {
console.error('Get encrypted key error:', err);
socket.emit('encrypted-key-result', { encryptedKey: null, salt: null, hasPublicKey: false, publicKey: null, state: 'error' });
}
});
// ── Preferences ─────────────────────────────────────────
socket.on('get-preferences', () => {
const rows = db.prepare('SELECT key, value FROM user_preferences WHERE user_id = ?').all(socket.user.id);
const prefs = {};
rows.forEach(r => { prefs[r.key] = r.value; });
socket.emit('preferences', prefs);
});
socket.on('set-preference', (data) => {
if (!data || typeof data !== 'object') return;
const key = typeof data.key === 'string' ? data.key.trim() : '';
const value = typeof data.value === 'string' ? data.value.trim() : '';
const allowedKeys = ['theme'];
if (!allowedKeys.includes(key) || !value || value.length > 50) return;
db.prepare(
'INSERT OR REPLACE INTO user_preferences (user_id, key, value) VALUES (?, ?, ?)'
).run(socket.user.id, key, value);
socket.emit('preference-saved', { key, value });
});
// ── High Scores ─────────────────────────────────────────
socket.on('submit-high-score', (data) => {
if (!data || typeof data !== 'object') return;
const game = typeof data.game === 'string' ? data.game.trim() : '';
const score = isInt(data.score) && data.score >= 0 ? data.score : 0;
if (!game || !/^[a-z0-9_-]{1,32}$/.test(game)) return;
const current = db.prepare(
'SELECT score FROM high_scores WHERE user_id = ? AND game = ?'
).get(socket.user.id, game);
if (!current || score > current.score) {
db.prepare(
'INSERT OR REPLACE INTO high_scores (user_id, game, score, updated_at) VALUES (?, ?, ?, datetime(\'now\'))'
).run(socket.user.id, game, score);
if (socket.currentChannel) {
io.to(socket.currentChannel).emit('new-high-score', {
username: socket.user.displayName,
game,
score,
previous: current ? current.score : 0
});
}
}
const leaderboard = db.prepare(`
SELECT hs.user_id, COALESCE(u.display_name, u.username) as username, hs.score
FROM high_scores hs JOIN users u ON hs.user_id = u.id
WHERE hs.game = ? AND hs.score > 0
ORDER BY hs.score DESC LIMIT 50
`).all(game);
io.emit('high-scores', { game, leaderboard });
});
socket.on('get-high-scores', (data) => {
if (!data || typeof data !== 'object') return;
const game = typeof data.game === 'string' ? data.game.trim() : 'flappy';
const leaderboard = db.prepare(`
SELECT hs.user_id, COALESCE(u.display_name, u.username) as username, hs.score
FROM high_scores hs JOIN users u ON hs.user_id = u.id
WHERE hs.game = ? AND hs.score > 0
ORDER BY hs.score DESC LIMIT 50
`).all(game);
socket.emit('high-scores', { game, leaderboard });
});
// ── Android Beta Signup ─────────────────────────────────
socket.on('android-beta-signup', (data, callback) => {
if (typeof callback !== 'function') return;
if (!data || !data.email || typeof data.email !== 'string') {
return callback({ ok: false, error: 'Invalid email.' });
}
const email = data.email.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 200) {
return callback({ ok: false, error: 'Invalid email address.' });
}
try {
const filePath = path.join(DATA_DIR, 'beta-signups.json');
let signups = [];
try { signups = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { /* first signup */ }
if (signups.some(s => s.email === email)) {
return callback({ ok: true });
}
signups.push({
email,
username: socket.user.username,
date: new Date().toISOString()
});
fs.writeFileSync(filePath, JSON.stringify(signups, null, 2));
console.log(`📱 Android beta signup: ${email} (${socket.user.username})`);
callback({ ok: true });
} catch (err) {
console.error('Beta signup error:', err);
callback({ ok: false, error: 'Server error — try again later.' });
}
});
};

640
src/socketHandlers/voice.js Normal file
View file

@ -0,0 +1,640 @@
'use strict';
const { isString, isInt } = require('./helpers');
module.exports = function register(socket, ctx) {
const { io, db, state, userHasPermission, getUserEffectiveLevel, getUserHighestRole,
broadcastVoiceUsers, emitOnlineUsers, handleVoiceLeave, touchVoiceActivity,
getActiveMusicSyncState, getMusicQueuePayload } = ctx;
const { channelUsers, voiceUsers, voiceLastActivity, activeMusic,
activeScreenSharers, activeWebcamUsers, streamViewers } = state;
// ── Local helper: broadcast stream/viewer info ──────────
function broadcastStreamInfo(code) {
const voiceRoom = voiceUsers.get(code);
if (!voiceRoom) return;
const sharers = activeScreenSharers.get(code);
const streams = [];
if (sharers) {
for (const sharerId of sharers) {
const sharerInfo = voiceRoom.get(sharerId);
const viewers = streamViewers.get(`${code}:${sharerId}`);
const viewerList = [];
if (viewers) {
for (const vid of viewers) {
const vInfo = voiceRoom.get(vid);
if (vInfo) viewerList.push({ id: vid, username: vInfo.username });
}
}
streams.push({
sharerId,
sharerName: sharerInfo ? sharerInfo.username : 'Unknown',
viewers: viewerList
});
}
}
io.to(`voice:${code}`).to(`channel:${code}`).emit('stream-viewers-update', { channelCode: code, streams });
}
// ── Voice join ──────────────────────────────────────────
socket.on('voice-join', (data) => {
if (!data || typeof data !== 'object') return;
const code = typeof data.code === 'string' ? data.code.trim() : '';
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
const vch = db.prepare('SELECT id FROM channels WHERE code = ?').get(code);
if (!vch) return;
const vMember = db.prepare(
'SELECT 1 FROM channel_members WHERE channel_id = ? AND user_id = ?'
).get(vch.id, socket.user.id);
if (!vMember) return socket.emit('error-msg', 'Not a member of this channel');
const vchSettings = db.prepare('SELECT voice_enabled, voice_user_limit, voice_bitrate FROM channels WHERE code = ?').get(code);
if (vchSettings && vchSettings.voice_enabled === 0) {
return socket.emit('error-msg', 'Voice is disabled in this channel');
}
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'use_voice', vch.id)) {
return socket.emit('error-msg', 'You don\'t have permission to use voice chat');
}
if (vchSettings && vchSettings.voice_user_limit > 0) {
const currentCount = voiceUsers.has(code) ? voiceUsers.get(code).size : 0;
if (currentCount >= vchSettings.voice_user_limit) {
return socket.emit('error-msg', `Voice is full (${currentCount}/${vchSettings.voice_user_limit})`);
}
}
// Leave any previous voice room first
for (const [prevCode, room] of voiceUsers) {
if (room.has(socket.user.id) && prevCode !== code) {
handleVoiceLeave(socket, prevCode);
}
}
if (!voiceUsers.has(code)) voiceUsers.set(code, new Map());
// If this user is already in the same voice channel (e.g. from another
// client/tab), do a full voice-leave on the old socket so peer connections,
// screen shares, and webcams are properly cleaned up. Then notify the old
// client so it resets its local voice UI.
const existingEntry = voiceUsers.get(code).get(socket.user.id);
if (existingEntry && existingEntry.socketId !== socket.id) {
const oldSocket = io.sockets.sockets.get(existingEntry.socketId);
if (oldSocket) {
handleVoiceLeave(oldSocket, code);
oldSocket.emit('voice-kicked', { channelCode: code, reason: 'Joined from another client' });
} else {
// Stale entry — socket already disconnected; just clean up the map
voiceUsers.get(code).delete(socket.user.id);
}
}
// Re-create the map if handleVoiceLeave cleaned it up (last user left)
if (!voiceUsers.has(code)) voiceUsers.set(code, new Map());
socket.join(`voice:${code}`);
const existingUsers = Array.from(voiceUsers.get(code).values())
.filter(u => u.id !== socket.user.id);
voiceUsers.get(code).set(socket.user.id, {
id: socket.user.id,
username: socket.user.displayName,
socketId: socket.id,
isMuted: false,
isDeafened: false
});
voiceLastActivity.set(socket.user.id, Date.now());
socket.emit('voice-existing-users', {
channelCode: code,
users: existingUsers.map(u => ({ id: u.id, username: u.username })),
voiceBitrate: vchSettings ? (vchSettings.voice_bitrate || 0) : 0
});
existingUsers.forEach(u => {
io.to(u.socketId).emit('voice-user-joined', {
channelCode: code,
user: { id: socket.user.id, username: socket.user.displayName }
});
});
broadcastVoiceUsers(code);
broadcastStreamInfo(code);
// Send active music state to late joiner
const music = activeMusic.get(code);
if (music) {
socket.emit('music-shared', {
userId: music.userId,
username: music.username,
url: music.url,
title: music.title,
trackId: music.id,
channelCode: code,
resolvedFrom: music.resolvedFrom,
syncState: getActiveMusicSyncState(music)
});
}
socket.emit('music-queue-update', getMusicQueuePayload(code));
// Send active screen share info — tell screen sharers to renegotiate
const sharers = activeScreenSharers.get(code);
if (sharers && sharers.size > 0) {
socket.emit('active-screen-sharers', {
channelCode: code,
sharers: Array.from(sharers).map(uid => {
const u = voiceUsers.get(code)?.get(uid);
return u ? { id: uid, username: u.username } : null;
}).filter(Boolean)
});
setTimeout(() => {
for (const sharerId of sharers) {
const sharerInfo = voiceUsers.get(code)?.get(sharerId);
if (sharerInfo) {
io.to(sharerInfo.socketId).emit('renegotiate-screen', {
targetUserId: socket.user.id,
channelCode: code
});
}
}
}, 2000);
}
// Send active webcam info — tell webcam users to renegotiate
const camUsers = activeWebcamUsers.get(code);
if (camUsers && camUsers.size > 0) {
socket.emit('active-webcam-users', {
channelCode: code,
users: Array.from(camUsers).map(uid => {
const u = voiceUsers.get(code)?.get(uid);
return u ? { id: uid, username: u.username } : null;
}).filter(Boolean)
});
setTimeout(() => {
for (const camUserId of camUsers) {
const camUserInfo = voiceUsers.get(code)?.get(camUserId);
if (camUserInfo) {
io.to(camUserInfo.socketId).emit('renegotiate-webcam', {
targetUserId: socket.user.id,
channelCode: code
});
}
}
}, 2500);
}
});
// ── WebRTC signaling ────────────────────────────────────
const MAX_SDP_SIZE = 16384; // 16 KB — generous limit for SDP offers/answers
const MAX_ICE_SIZE = 2048; // 2 KB — ICE candidates are small
socket.on('voice-offer', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8) || !isInt(data.targetUserId) || !data.offer) return;
if (typeof data.offer !== 'object' || JSON.stringify(data.offer).length > MAX_SDP_SIZE) 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-offer', {
from: { id: socket.user.id, username: socket.user.displayName },
offer: data.offer,
channelCode: data.code
});
}
});
socket.on('voice-answer', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8) || !isInt(data.targetUserId) || !data.answer) return;
if (typeof data.answer !== 'object' || JSON.stringify(data.answer).length > MAX_SDP_SIZE) 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', {
from: { id: socket.user.id, username: socket.user.displayName },
answer: data.answer,
channelCode: data.code
});
}
});
socket.on('voice-ice-candidate', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8) || !isInt(data.targetUserId)) return;
if (data.candidate && (typeof data.candidate !== 'object' || JSON.stringify(data.candidate).length > MAX_ICE_SIZE)) 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', {
from: { id: socket.user.id, username: socket.user.displayName },
candidate: data.candidate,
channelCode: data.code
});
}
});
// ── Voice leave ─────────────────────────────────────────
socket.on('voice-leave', (data, callback) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
handleVoiceLeave(socket, data.code);
if (typeof callback === 'function') callback({ ok: true });
});
// ── Voice kick ──────────────────────────────────────────
socket.on('voice-kick', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
if (!isInt(data.userId)) return;
if (data.userId === socket.user.id) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const target = voiceRoom.get(data.userId);
if (!target) return socket.emit('error-msg', 'User is not in voice');
const kickCh = db.prepare('SELECT id FROM channels WHERE code = ?').get(data.code);
const channelId = kickCh ? kickCh.id : null;
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'kick_user', channelId)) {
return socket.emit('error-msg', 'You don\'t have permission to kick users from voice');
}
const myLevel = getUserEffectiveLevel(socket.user.id, channelId);
const targetLevel = getUserEffectiveLevel(data.userId, channelId);
if (targetLevel >= myLevel) {
return socket.emit('error-msg', 'You can\'t kick a user with equal or higher rank');
}
voiceRoom.delete(data.userId);
const targetSocket = io.sockets.sockets.get(target.socketId);
if (targetSocket) {
targetSocket.leave(`voice:${data.code}`);
}
const sharers = activeScreenSharers.get(data.code);
if (sharers) { sharers.delete(data.userId); if (sharers.size === 0) activeScreenSharers.delete(data.code); }
const camUsersSet = activeWebcamUsers.get(data.code);
if (camUsersSet) { camUsersSet.delete(data.userId); if (camUsersSet.size === 0) activeWebcamUsers.delete(data.code); }
const viewerKey = `${data.code}:${data.userId}`;
streamViewers.delete(viewerKey);
for (const [key, viewers] of streamViewers) {
if (key.startsWith(data.code + ':')) {
viewers.delete(data.userId);
if (viewers.size === 0) streamViewers.delete(key);
}
}
io.to(target.socketId).emit('voice-kicked', {
channelCode: data.code,
kickedBy: socket.user.displayName
});
for (const [, user] of voiceRoom) {
io.to(user.socketId).emit('voice-user-left', {
channelCode: data.code,
user: { id: data.userId, username: target.username }
});
}
broadcastVoiceUsers(data.code);
broadcastStreamInfo(data.code);
socket.emit('error-msg', `Kicked ${target.username} from voice`);
});
// ── Screen sharing ──────────────────────────────────────
socket.on('screen-share-started', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const streamChannel = db.prepare('SELECT streams_enabled FROM channels WHERE code = ?').get(data.code);
if (streamChannel && streamChannel.streams_enabled === 0 && !socket.user.isAdmin) {
return socket.emit('error-msg', 'Screen sharing is disabled in this channel');
}
if (!activeScreenSharers.has(data.code)) activeScreenSharers.set(data.code, new Set());
activeScreenSharers.get(data.code).add(socket.user.id);
for (const [uid, user] of voiceRoom) {
if (uid !== socket.user.id) {
io.to(user.socketId).emit('screen-share-started', {
userId: socket.user.id,
username: socket.user.displayName,
channelCode: data.code,
hasAudio: !!data.hasAudio
});
}
}
broadcastStreamInfo(data.code);
});
socket.on('screen-share-stopped', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const sharers = activeScreenSharers.get(data.code);
if (sharers) { sharers.delete(socket.user.id); if (sharers.size === 0) activeScreenSharers.delete(data.code); }
const viewerKey = `${data.code}:${socket.user.id}`;
streamViewers.delete(viewerKey);
for (const [uid, user] of voiceRoom) {
if (uid !== socket.user.id) {
io.to(user.socketId).emit('screen-share-stopped', {
userId: socket.user.id,
channelCode: data.code
});
}
}
broadcastStreamInfo(data.code);
});
// ── Webcam ──────────────────────────────────────────────
socket.on('webcam-started', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
if (!activeWebcamUsers.has(data.code)) activeWebcamUsers.set(data.code, new Set());
activeWebcamUsers.get(data.code).add(socket.user.id);
for (const [uid, user] of voiceRoom) {
if (uid !== socket.user.id) {
io.to(user.socketId).emit('webcam-started', {
userId: socket.user.id,
username: socket.user.displayName,
channelCode: data.code
});
}
}
});
socket.on('webcam-stopped', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const camUsersSet = activeWebcamUsers.get(data.code);
if (camUsersSet) {
camUsersSet.delete(socket.user.id);
if (camUsersSet.size === 0) activeWebcamUsers.delete(data.code);
}
for (const [uid, user] of voiceRoom) {
if (uid !== socket.user.id) {
io.to(user.socketId).emit('webcam-stopped', {
userId: socket.user.id,
channelCode: data.code
});
}
}
});
// ── Stream viewer tracking ──────────────────────────────
socket.on('stream-watch', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
if (!isInt(data.sharerId)) return;
const voiceRoom = voiceUsers.get(data.code);
if (!voiceRoom || !voiceRoom.has(socket.user.id)) return;
const key = `${data.code}:${data.sharerId}`;
if (!streamViewers.has(key)) streamViewers.set(key, new Set());
streamViewers.get(key).add(socket.user.id);
broadcastStreamInfo(data.code);
});
socket.on('stream-unwatch', (data) => {
if (!data || typeof data !== 'object') return;
if (!isString(data.code, 8, 8)) return;
if (!isInt(data.sharerId)) return;
const viewers = streamViewers.get(`${data.code}:${data.sharerId}`);
if (viewers) {
viewers.delete(socket.user.id);
if (viewers.size === 0) streamViewers.delete(`${data.code}:${data.sharerId}`);
}
broadcastStreamInfo(data.code);
});
// ── Voice state ─────────────────────────────────────────
socket.on('request-online-users', (data) => {
if (!data || typeof data !== 'object') return;
const code = typeof data.code === 'string' ? data.code.trim() : '';
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
emitOnlineUsers(code);
});
socket.on('request-voice-users', (data) => {
if (!data || typeof data !== 'object') return;
const code = typeof data.code === 'string' ? data.code.trim() : '';
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(code);
const channelId = channel ? channel.id : null;
const room = voiceUsers.get(code);
const users = room
? Array.from(room.values()).map(u => {
const role = getUserHighestRole(u.id, channelId);
return { id: u.id, username: u.username, roleColor: role ? role.color : null, isMuted: u.isMuted || false, isDeafened: u.isDeafened || false };
})
: [];
socket.emit('voice-users-update', { channelCode: code, users });
});
socket.on('voice-mute-state', (data) => {
if (!data || typeof data !== 'object') return;
const code = typeof data.code === 'string' ? data.code.trim() : '';
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
const room = voiceUsers.get(code);
if (!room || !room.has(socket.user.id)) return;
room.get(socket.user.id).isMuted = !!data.muted;
if (!data.muted) touchVoiceActivity(socket.user.id);
broadcastVoiceUsers(code);
});
socket.on('voice-speaking', (data) => {
if (!data || typeof data !== 'object') return;
for (const [code, room] of voiceUsers) {
if (room.has(socket.user.id)) {
io.to(`voice:${code}`).emit('voice-speaking', {
userId: socket.user.id,
speaking: !!data.speaking
});
break;
}
}
});
socket.on('voice-activity', () => {
touchVoiceActivity(socket.user.id);
if (socket.user.status === 'away') {
try {
db.prepare('UPDATE users SET status = ? WHERE id = ?').run('online', socket.user.id);
socket.user.status = 'online';
for (const [code, users] of channelUsers) {
if (users.has(socket.user.id)) {
users.get(socket.user.id).status = 'online';
emitOnlineUsers(code);
}
}
socket.emit('status-updated', { status: 'online', statusText: socket.user.statusText || '' });
} catch { /* ignore */ }
}
});
socket.on('voice-deafen-state', (data) => {
if (!data || typeof data !== 'object') return;
const code = typeof data.code === 'string' ? data.code.trim() : '';
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
const room = voiceUsers.get(code);
if (!room || !room.has(socket.user.id)) return;
room.get(socket.user.id).isDeafened = !!data.deafened;
broadcastVoiceUsers(code);
});
// ── Voice rejoin (after reconnect) ──────────────────────
socket.on('voice-rejoin', (data) => {
if (!data || typeof data !== 'object') return;
const code = typeof data.code === 'string' ? data.code.trim() : '';
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
const vch = db.prepare('SELECT id FROM channels WHERE code = ?').get(code);
if (!vch) return;
const vMember = db.prepare(
'SELECT 1 FROM channel_members WHERE channel_id = ? AND user_id = ?'
).get(vch.id, socket.user.id);
if (!vMember) return;
for (const [prevCode, room] of voiceUsers) {
if (room.has(socket.user.id) && prevCode !== code) {
handleVoiceLeave(socket, prevCode);
}
}
if (!voiceUsers.has(code)) voiceUsers.set(code, new Map());
socket.join(`voice:${code}`);
voiceUsers.get(code).set(socket.user.id, {
id: socket.user.id,
username: socket.user.displayName,
socketId: socket.id,
isMuted: false,
isDeafened: false
});
const existingUsers = Array.from(voiceUsers.get(code).values())
.filter(u => u.id !== socket.user.id);
socket.emit('voice-existing-users', {
channelCode: code,
users: existingUsers.map(u => ({ id: u.id, username: u.username }))
});
existingUsers.forEach(u => {
io.to(u.socketId).emit('voice-user-joined', {
channelCode: code,
user: { id: socket.user.id, username: socket.user.displayName }
});
});
broadcastVoiceUsers(code);
broadcastStreamInfo(code);
const music = activeMusic.get(code);
if (music) {
socket.emit('music-shared', {
userId: music.userId,
username: music.username,
url: music.url,
title: music.title,
trackId: music.id,
channelCode: code,
resolvedFrom: music.resolvedFrom,
syncState: getActiveMusicSyncState(music)
});
}
socket.emit('music-queue-update', getMusicQueuePayload(code));
const sharers = activeScreenSharers.get(code);
if (sharers && sharers.size > 0) {
socket.emit('active-screen-sharers', {
channelCode: code,
sharers: Array.from(sharers).map(uid => {
const u = voiceUsers.get(code)?.get(uid);
return u ? { id: uid, username: u.username } : null;
}).filter(Boolean)
});
setTimeout(() => {
for (const sharerId of sharers) {
const sharerInfo = voiceUsers.get(code)?.get(sharerId);
if (sharerInfo) {
io.to(sharerInfo.socketId).emit('renegotiate-screen', {
targetUserId: socket.user.id,
channelCode: code
});
}
}
}, 2000);
}
const camUsers = activeWebcamUsers.get(code);
if (camUsers && camUsers.size > 0) {
socket.emit('active-webcam-users', {
channelCode: code,
users: Array.from(camUsers).map(uid => {
const u = voiceUsers.get(code)?.get(uid);
return u ? { id: uid, username: u.username } : null;
}).filter(Boolean)
});
setTimeout(() => {
for (const camUserId of camUsers) {
const camUserInfo = voiceUsers.get(code)?.get(camUserId);
if (camUserInfo) {
io.to(camUserInfo.socketId).emit('renegotiate-webcam', {
targetUserId: socket.user.id,
channelCode: code
});
}
}
}, 2500);
}
});
// ── Voice counts / channel members ──────────────────────
socket.on('get-voice-counts', () => {
for (const [code, room] of voiceUsers) {
if (room.size > 0) {
const users = Array.from(room.values()).map(u => ({ id: u.id, username: u.username, isMuted: u.isMuted || false, isDeafened: u.isDeafened || false }));
socket.emit('voice-count-update', { code, count: room.size, users });
}
}
});
socket.on('get-channel-members', (data) => {
if (!data || typeof data !== 'object') return;
const code = typeof data.code === 'string' ? data.code.trim() : '';
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').get(code);
if (!channel) return;
const member = db.prepare(
'SELECT 1 FROM channel_members WHERE channel_id = ? AND user_id = ?'
).get(channel.id, socket.user.id);
if (!member) return;
const members = db.prepare(`
SELECT u.id, COALESCE(u.display_name, u.username) as username, u.username as loginName FROM users u
JOIN channel_members cm ON u.id = cm.user_id
WHERE cm.channel_id = ?
ORDER BY COALESCE(u.display_name, u.username)
`).all(channel.id);
socket.emit('channel-members', { channelCode: code, members });
});
};

View file

@ -953,13 +953,13 @@
<span class="discord-feat">🖥️ Windows &amp; Linux</span>
</div>
<div style="margin-top: 28px; display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.2.0/Haven-Setup-1.2.0.exe" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/Haven-Setup-1.3.0.exe" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Windows Installer
</a>
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.2.0/Haven-1.2.0.AppImage" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/Haven-1.3.0.AppImage" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Linux AppImage
</a>
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.2.0/haven-desktop_1.2.0_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<a href="https://github.com/ancsemi/Haven-Desktop/releases/download/v1.3.0/haven-desktop_1.3.0_amd64.deb" class="btn btn-primary" style="padding: 12px 24px; font-size: 1rem;">
<span class="icon"></span> Linux .deb
</a>
</div>
@ -1415,12 +1415,12 @@
</div>
<div class="download-card fade-in">
<h2>&#x2B21; Haven Server &mdash; v2.9.7</h2>
<h2>&#x2B21; Haven Server &mdash; v3.5.0</h2>
<p class="download-version">Latest stable release &middot; Windows, macOS &amp; Linux &middot; ~5 MB</p>
<div class="download-btn-group">
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v2.9.7 (.zip)
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v3.5.0 (.zip)
</a>
<div class="download-alt-links">
<a href="https://github.com/ancsemi/Haven" target="_blank">&#9965; View on GitHub</a>
@ -1437,7 +1437,43 @@
<div class="version-list">
<div class="version-list-inner">
<div class="version-item">
<div><span class="v-name">v2.9.7</span><span class="v-tag latest">Latest</span></div>
<div><span class="v-name">v3.5.0</span><span class="v-tag latest">Latest</span></div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.5.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.4.0</span> &mdash; Quote/edit UX, bot API upgrades, SSO quality fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.4.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.3.0</span> &mdash; Last read indicator, per-event volume sliders, server list sync improvements</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.3.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.2.0</span> &mdash; Mark as Read, pinned message jump, iOS Safari fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.2.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.1.1</span> &mdash; Status bar toggle, server URL display, mobile fixes</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.1.1.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.1.0</span> &mdash; Server banners, server icon sync</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.1.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v3.0.0</span> &mdash; SSO registration, advanced search filters, reply notifications</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.0.0.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v2.9.9</span> &mdash; Encrypted server list sync, jump-to-bottom, edit-mode emoji picker</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.9.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v2.9.8</span> &mdash; Read-only channels, server-relayed mic illumination, role display picker</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.8.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v2.9.7</span> &mdash; Open-source STUN servers, STUN_URLS env var</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip">Download &rarr;</a>
</div>
<div class="version-item">