mirror of
https://github.com/ancsemi/Haven
synced 2026-04-21 13:37:41 +00:00
Compare commits
95 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0d8f2006 | ||
|
|
9f2f4dfd74 | ||
|
|
feb357bd9f | ||
|
|
bb02f49526 | ||
|
|
0cacae73b3 | ||
|
|
dd8258d46b | ||
|
|
83fbfb5fd4 | ||
|
|
47454daead | ||
|
|
e59a414bb6 | ||
|
|
dd2df1727a | ||
|
|
e6a0caa0e7 | ||
|
|
8715392e50 | ||
|
|
0c2b4a8be4 | ||
|
|
17519d5a09 | ||
|
|
b56dff0535 | ||
|
|
76336b74fd | ||
|
|
f05a62d14e | ||
|
|
7f866838ce | ||
|
|
032b4aee6a | ||
|
|
28108dae56 | ||
|
|
923576a015 | ||
|
|
b964b1c23c | ||
|
|
c48534b6ae | ||
|
|
69b03592bb | ||
|
|
b02b9a9ff7 | ||
|
|
316494f877 | ||
|
|
fe5a17d178 | ||
|
|
93479775ae | ||
|
|
00c6689c89 | ||
|
|
5a02e41e49 | ||
|
|
a5df2d9b13 | ||
|
|
9f5da48976 | ||
|
|
564bae8750 | ||
|
|
e4db71dd09 | ||
|
|
0d1858f747 | ||
|
|
f2827690a0 | ||
|
|
8befde0b15 | ||
|
|
f5e94877f0 | ||
|
|
2d82cc697c | ||
|
|
a9015373d1 | ||
|
|
0c99b7ea68 | ||
|
|
183946c406 | ||
|
|
47c2bbe441 | ||
|
|
359f461ce8 | ||
|
|
c022105478 | ||
|
|
b189eaa69a | ||
|
|
e567d070ea | ||
|
|
8005f9cdfd | ||
|
|
f44a15b7c2 | ||
|
|
161eca23e4 | ||
|
|
c5529da569 | ||
|
|
61e5c7b922 | ||
|
|
fdfac9f79b | ||
|
|
f4dfa217a0 | ||
|
|
1baf463458 | ||
|
|
075243d816 | ||
|
|
cad5eb2fc9 | ||
|
|
593e269030 | ||
|
|
612440ec65 | ||
|
|
7a640d4662 | ||
|
|
4b90088094 | ||
|
|
a0db79c344 | ||
|
|
b764c35f83 | ||
|
|
39c06f09a2 | ||
|
|
35e0fd668b | ||
|
|
6a6fc695b0 | ||
|
|
b208486a98 | ||
|
|
7b3c3a7d60 | ||
|
|
753e05ab46 | ||
|
|
5e3ef96885 | ||
|
|
c3b217323b | ||
|
|
47a4bc1959 | ||
|
|
4ab8275058 | ||
|
|
2c8a941e93 | ||
|
|
5bb674711c | ||
|
|
34a5fcd35d | ||
|
|
af5b28e4be | ||
|
|
b3bc43cf67 | ||
|
|
b3ac781061 | ||
|
|
21ce77aa42 | ||
|
|
b5536f06da | ||
|
|
e3348dfc71 | ||
|
|
722878b780 | ||
|
|
36eddeba5f | ||
|
|
18efdfcadf | ||
|
|
695e0aad28 | ||
|
|
ce501736aa | ||
|
|
86a3a5b3f1 | ||
|
|
85fdfdba79 | ||
|
|
9a1ce54c84 | ||
|
|
a087b86f1b | ||
|
|
32020e9c3c | ||
|
|
c4d5af5a59 | ||
|
|
fad2a5d73e | ||
|
|
6e97a7a35c |
52 changed files with 14717 additions and 7987 deletions
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
186
CHANGELOG.md
186
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
87
GUIDE.md
87
GUIDE.md
|
|
@ -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**
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -953,13 +953,13 @@
|
|||
<span class="discord-feat">🖥️ Windows & 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>⬡ Haven Server — v2.9.7</h2>
|
||||
<h2>⬡ Haven Server — v3.5.0</h2>
|
||||
<p class="download-version">Latest stable release · Windows, macOS & Linux · ~5 MB</p>
|
||||
|
||||
<div class="download-btn-group">
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip" class="btn btn-primary download-main">
|
||||
<span class="icon">⬇</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">⬇</span> Download v3.5.0 (.zip)
|
||||
</a>
|
||||
<div class="download-alt-links">
|
||||
<a href="https://github.com/ancsemi/Haven" target="_blank">⛭ 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.4.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.3.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.2.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.1.1</span> — Status bar toggle, server URL display, mobile fixes</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.1.1.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.1.0</span> — Server banners, server icon sync</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.1.0.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.0.0</span> — SSO registration, advanced search filters, reply notifications</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.0.0.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.9.9</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.9.8</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.9.7</span> — Open-source STUN servers, STUN_URLS env var</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
|
|
|
|||
195
docs/server-list-sync.md
Normal file
195
docs/server-list-sync.md
Normal 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
33
donor-order.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -25,6 +25,9 @@
|
|||
"Orange Lantern",
|
||||
"lataxd9",
|
||||
"HoppyGamers",
|
||||
"deNully"
|
||||
"deNully",
|
||||
"Morgan",
|
||||
"Taylan",
|
||||
"birdycrazy"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
637
public/app.html
637
public/app.html
|
|
@ -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">×</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">×</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">×</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">×</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 & 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 & 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 & 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">×</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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
1483
public/css/style.css
1483
public/css/style.css
File diff suppressed because it is too large
Load diff
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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 ────────────
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
179
public/js/e2e.js
179
public/js/e2e.js
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)>\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)((?:>[^\n]*(?:\n|$))+)/g, (full, pre, block) => {
|
||||
const lines = block.trim().split('\n').map(line => line.replace(/^>\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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
"msg_toolbar": {
|
||||
"react": "Reagieren",
|
||||
"reply": "Antworten",
|
||||
"quote": "Zitieren",
|
||||
"pin": "Anheften",
|
||||
"unpin": "Lösen",
|
||||
"edit": "Bearbeiten",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
"msg_toolbar": {
|
||||
"react": "Reaccionar",
|
||||
"reply": "Responder",
|
||||
"quote": "Citar",
|
||||
"pin": "Fijar",
|
||||
"unpin": "Desfijar",
|
||||
"edit": "Editar",
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
"msg_toolbar": {
|
||||
"react": "Réagir",
|
||||
"reply": "Répondre",
|
||||
"quote": "Citer",
|
||||
"pin": "Épingler",
|
||||
"unpin": "Désépingler",
|
||||
"edit": "Modifier",
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
"msg_toolbar": {
|
||||
"react": "Реакция",
|
||||
"reply": "Ответить",
|
||||
"quote": "Цитировать",
|
||||
"pin": "Закрепить",
|
||||
"unpin": "Открепить",
|
||||
"edit": "Изменить",
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
"msg_toolbar": {
|
||||
"react": "回应",
|
||||
"reply": "回复",
|
||||
"quote": "引用",
|
||||
"pin": "置顶",
|
||||
"unpin": "取消置顶",
|
||||
"edit": "编辑",
|
||||
|
|
|
|||
769
server.js
769
server.js
|
|
@ -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));
|
||||
|
|
|
|||
505
src/auth.js
505
src/auth.js
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
495
src/socketHandlers/admin.js
Normal 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' });
|
||||
}
|
||||
});
|
||||
};
|
||||
1130
src/socketHandlers/channels.js
Normal file
1130
src/socketHandlers/channels.js
Normal file
File diff suppressed because it is too large
Load diff
59
src/socketHandlers/helpers.js
Normal file
59
src/socketHandlers/helpers.js
Normal 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. ' → ' stored → &#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
1118
src/socketHandlers/index.js
Normal file
File diff suppressed because it is too large
Load diff
1338
src/socketHandlers/messages.js
Normal file
1338
src/socketHandlers/messages.js
Normal file
File diff suppressed because it is too large
Load diff
459
src/socketHandlers/moderation.js
Normal file
459
src/socketHandlers/moderation.js
Normal 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
350
src/socketHandlers/music.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
288
src/socketHandlers/musicResolver.js
Normal file
288
src/socketHandlers/musicResolver.js
Normal 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
|
||||
};
|
||||
179
src/socketHandlers/permissions.js
Normal file
179
src/socketHandlers/permissions.js
Normal 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
747
src/socketHandlers/roles.js
Normal 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
524
src/socketHandlers/users.js
Normal 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
640
src/socketHandlers/voice.js
Normal 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 });
|
||||
});
|
||||
};
|
||||
|
|
@ -953,13 +953,13 @@
|
|||
<span class="discord-feat">🖥️ Windows & 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>⬡ Haven Server — v2.9.7</h2>
|
||||
<h2>⬡ Haven Server — v3.5.0</h2>
|
||||
<p class="download-version">Latest stable release · Windows, macOS & Linux · ~5 MB</p>
|
||||
|
||||
<div class="download-btn-group">
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip" class="btn btn-primary download-main">
|
||||
<span class="icon">⬇</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">⬇</span> Download v3.5.0 (.zip)
|
||||
</a>
|
||||
<div class="download-alt-links">
|
||||
<a href="https://github.com/ancsemi/Haven" target="_blank">⛭ 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.4.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.3.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.2.0</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.1.1</span> — Status bar toggle, server URL display, mobile fixes</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.1.1.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.1.0</span> — Server banners, server icon sync</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.1.0.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v3.0.0</span> — SSO registration, advanced search filters, reply notifications</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v3.0.0.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.9.9</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.9.8</span> — 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 →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<div><span class="v-name">v2.9.7</span> — Open-source STUN servers, STUN_URLS env var</div>
|
||||
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip">Download →</a>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
|
|
|
|||
Loading…
Reference in a new issue