diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index dd3e5e00d6..7a93d806ab 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -6,12 +6,13 @@ on:
- main
- master
- develop
+ - 'dev-*'
pull_request:
branches:
- main
- master
- develop
-
+ - 'dev-*'
defaults:
run:
shell: bash
@@ -89,4 +90,4 @@ jobs:
run: npm run coverage
- name: Test coverage
- uses: coverallsapp/github-action@v2
\ No newline at end of file
+ uses: coverallsapp/github-action@v2
diff --git a/.github/workflows/vm_deploy.yml b/.github/workflows/vm_deploy.yml
new file mode 100644
index 0000000000..06247d08e0
--- /dev/null
+++ b/.github/workflows/vm_deploy.yml
@@ -0,0 +1,36 @@
+name: VM Deploy NodeBB
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: true
+
+jobs:
+ build-and-deploy:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Node.js version
+ uses: actions/setup-node@v3
+ with:
+ node-version: '20.17.0'
+
+ - name: Deploy via SSH
+ uses: appleboy/ssh-action@v1.2.1
+ with:
+ host: ${{ secrets.SERVER_IP }}
+ username: ${{ secrets.SERVER_ADMIN }}
+ password: ${{ secrets.ADMIN_PASSWORD }}
+ script: |
+ cd ${{ secrets.REPO_NAME }}
+ git pull origin main
+ docker compose down
+ docker compose -f docker-compose-redis.yml up --build -d
diff --git a/.gitignore b/.gitignore
index 17bbb8e5f5..c0d5a57338 100644
--- a/.gitignore
+++ b/.gitignore
@@ -78,3 +78,5 @@ test.sh
dump.rdb
.archiver_shadow/
.snapshots/
+
+start-dev.sh
diff --git a/.semgrepignore b/.semgrepignore
new file mode 100644
index 0000000000..f28d3ac9c7
--- /dev/null
+++ b/.semgrepignore
@@ -0,0 +1,9 @@
+# Semgrep ignore patterns (gitignore-style)
+# node_modules/ and vendor/ are ignored by semgrep by default
+node_modules/
+vendor/
+build/
+public/language/
+coverage/
+.nyc_output/
+*.min.js
diff --git a/UserGuide.md b/UserGuide.md
new file mode 100644
index 0000000000..af3631fed7
--- /dev/null
+++ b/UserGuide.md
@@ -0,0 +1,181 @@
+# User Guide — Sprint Features
+
+This guide covers the three features implemented during this sprint: **Message Forwarding**, **Message Reactions**, and **Custom Color Scheme**.
+
+---
+
+## Table of Contents
+
+1. [Message Forwarding](#1-message-forwarding)
+2. [Message Reactions](#2-message-reactions)
+3. [Custom Color Scheme](#3-custom-color-scheme)
+4. [Automated Tests](#4-automated-tests)
+
+---
+
+## 1. Message Forwarding
+
+### Overview
+
+Message Forwarding allows a registered user to share a chat message from one chat room to another. The forwarded message appears in the destination room with a visual reference to the original message and its sender.
+
+### How to Use
+
+1. **Open a chat room** — Navigate to your chats and open any conversation.
+2. **Hover over a message** — Hover over the message you want to forward. A toolbar of action buttons will appear.
+3. **Click the Forward button** — Click the **share icon** () in the message toolbar.
+4. **Select a destination** — A dropdown appears listing all of your other chat rooms (both private and public). You can **search** for a specific room using the search bar at the top of the dropdown.
+5. **Click a chat to forward** — Click the desired room. A confirmation alert will appear, and the message will be sent to the selected room.
+6. **View the forwarded message** — In the destination room, the forwarded message appears with:
+ - A **share icon** and the **original sender's avatar and name**
+ - A **preview of the original message content**
+ - The new message body (labeled "(Forwarded Message)")
+
+### User Testing Steps
+
+| Step | Action | Expected Result |
+|------|--------|-----------------|
+| 1 | Open a chat room with at least one message | Messages display normally |
+| 2 | Hover over a message | Action toolbar appears (reply, forward, ellipsis menu) |
+| 3 | Click the forward (share) button | A "Forward to:" dropdown opens with a search bar and a list of your other chat rooms |
+| 4 | Type in the search field | Room list filters in real time |
+| 5 | Click a room in the list | A success alert ("Message forwarded") appears, and the dropdown closes |
+| 6 | Navigate to the destination room | The forwarded message appears with the original sender info and content preview |
+| 7 | Click the forward button again, then click the ✕ close button on the dropdown | The dropdown closes without forwarding |
+| 8 | Click outside the dropdown | The dropdown closes |
+
+---
+
+## 2. Message Reactions
+
+### Overview
+
+Message Reactions allow registered users to react to any chat message with an emoji. Reactions are visible to all room members and update in **real time** via WebSocket events.
+
+### How to Use
+
+1. **Open a chat room** — Navigate to a chat conversation.
+2. **Add a reaction** — Below each message, click the **smiley face button** (😊). An emoji picker will appear.
+3. **Select an emoji** — Choose any emoji from the picker. A **reaction pill** will appear below the message, showing the emoji and a count of `1`.
+4. **Toggle a reaction** — Click an existing reaction pill to add your reaction (if you haven't reacted with that emoji) or to remove it (if you already have).
+5. **View who reacted** — Reaction pills you've personally used are highlighted with a distinct "reacted" style.
+6. **Real-time updates** — When another user in the room adds or removes a reaction, your view updates automatically.
+
+### User Testing Steps
+
+| Step | Action | Expected Result |
+|------|--------|-----------------|
+| 1 | Open a chat room with messages | Messages appear normally, with a small smiley button visible below each message |
+| 2 | Click the smiley (add reaction) button on a message | An emoji picker dialog opens |
+| 3 | Select an emoji (e.g., 👍) | A reaction pill appears below the message with the emoji and count "1" |
+| 4 | Click the same reaction pill again | The reaction toggles off — the pill disappears (count drops to 0) |
+| 5 | Add a reaction, then have a **second user** react with the same emoji | The count on the pill increases to "2"; the first user's pill shows the "reacted" highlight |
+| 6 | As the second user, view the reaction pill | The pill appears without the "reacted" highlight (since it reflects only the current user's state) |
+| 7 | Send an invalid emoji string via the API (e.g., `notanemoji`) | The server returns a 400 error |
+| 8 | Attempt to react as a user **not in the room** | The server returns a 403 error |
+
+---
+
+## 3. Custom Color Scheme
+
+### Overview
+
+Custom Color Scheme lets each registered user create a personalized color theme for their NodeBB interface. When the **"Custom"** skin is selected, 7 color pickers appear, allowing the user to control header, body, link, and button colors.
+
+### How to Use
+
+1. **Go to Account Settings** — Click your profile picture → **Settings** (or navigate to `/user//settings`).
+2. **Select the "Custom" skin** — In the **Skin** dropdown, choose **"Custom"**.
+3. **Adjust colors** — Seven color picker groups will appear:
+
+ | Setting | What It Controls |
+ |---------|-----------------|
+ | Header Background | Background color of the sidebar / navigation areas |
+ | Header Text | Text color in the sidebar / navigation areas |
+ | Body Background | Main page background color |
+ | Body Text | Default text color across the page |
+ | Link Color | Color of all hyperlinks |
+ | Button Background | Background color of primary buttons |
+ | Button Text | Text color on primary buttons |
+
+4. **Preview in real time** — Colors are applied instantly as you pick them, so you can see the result before saving.
+5. **Save** — Click **"Save Changes"** at the bottom of the settings page. Your custom colors are stored and will persist across sessions and page reloads.
+6. **Reset** — Click the **"Reset Colors"** button to clear all custom color values and revert to the defaults.
+7. **Switch skins** — Selecting a different skin from the dropdown hides the color pickers and applies the selected Bootswatch theme instead. Switching back to "Custom" restores your previously saved colors.
+
+### User Testing Steps
+
+| Step | Action | Expected Result |
+|------|--------|-----------------|
+| 1 | Navigate to Account → Settings | Settings page loads with a Skin dropdown |
+| 2 | Select "Custom" from the Skin dropdown | Seven color pickers appear below the dropdown |
+| 3 | Set a Body Background color (e.g., `#1a1a2e`) | The page background changes immediately |
+| 4 | Set Header Background / Text colors | Both sidebars and the brand area update |
+| 5 | Set Button Background / Text colors | All primary buttons (including pagination, badges, etc.) update |
+| 6 | Set a Link Color | All hyperlinks change color |
+| 7 | Click "Save Changes", then reload the page | All custom colors persist after reload (no flash of unstyled content) |
+| 8 | Click "Reset Colors" | All color pickers clear and the page reverts to default styling |
+| 9 | Switch to a different skin (e.g., Darkly) | Color pickers disappear; the Darkly theme is applied |
+| 10 | Switch back to "Custom" | Color pickers reappear with previously saved colors |
+| 11 | Enter an invalid color value via the API (e.g., `not-a-color`) | The server rejects it with error `[[error:invalid-theme-color]]` |
+| 12 | Open a new browser/incognito window and log in | Custom colors load immediately on every page (FOUC prevention) |
+
+---
+
+## 4. Automated Tests
+
+### Message Forwarding Tests
+
+- **File:** [`test/messaging.js`](test/messaging.js) — `forwardMid` describe block (lines 631–674)
+- **How to run:**
+ ```bash
+ npx mocha test/messaging.js --grep "forwardMid" --timeout 60000
+ ```
+
+| Test | What It Validates |
+|------|-------------------|
+| `should fail if forwardMid is not a number` | Rejects non-numeric `forwardMid` values with "Invalid Chat Message ID" |
+| `should forward firstMid using forwardMid` | A forwarded message round-trips correctly — `forwardedMessage` is present in the response with the correct `mid` and `content` |
+
+**Why these tests are sufficient:** They cover the two critical paths: input validation (rejecting invalid IDs) and the full end-to-end flow (sending a forwarded message and verifying the forwarded content is correctly linked and returned in the message payload).
+
+---
+
+### Message Reactions Tests
+
+- **File:** [`test/messaging.js`](test/messaging.js) — `Emoji Reactions` describe block (lines 880–941)
+- **How to run:**
+ ```bash
+ npx mocha test/messaging.js --grep "Emoji Reactions" --timeout 60000
+ ```
+
+| Test | What It Validates |
+|------|-------------------|
+| `should allow a user to add a reaction` | A user can successfully add a reaction; response confirms `added: true` |
+| `should toggle off a reaction if the same emoji is sent again` | Sending the same emoji again removes the reaction (`added: false`) |
+| `should return reactions with correct count and self flag` | Two users reacting with the same emoji yields `count: 2` and the requesting user sees `self: true` |
+| `should reflect self as false for a user who has not reacted` | A non-reacting user sees `self: false` |
+| `should reject an invalid emoji` | Non-emoji strings are rejected with a 400 error |
+| `should reject a reaction from a user not in the room` | Unauthorized users receive a 403 error |
+| `should include reactions in message payload` | Reactions are automatically included when fetching a message |
+
+**Why these tests are sufficient:** They cover adding, toggling, counting, per-user state (`self` flag), input validation (invalid emoji), authorization (non-member rejection), and payload integration. Together, these tests exercise every backend code path in the reactions module.
+
+---
+
+### Custom Color Scheme Tests
+
+- **File:** [`test/user.js`](test/user.js) — `custom theme` tests
+- **How to run:**
+ ```bash
+ npx mocha test/user.js --grep "custom theme" --timeout 60000
+ ```
+
+| Test | What It Validates |
+|------|-------------------|
+| `should save custom theme color settings` | All 7 color fields round-trip correctly through save and load |
+| `should reject invalid custom theme color values` | Non-hex values are rejected with `[[error:invalid-theme-color]]` |
+| `should allow empty custom theme color values` | Empty strings are accepted (allows users to clear individual colors) |
+| `should sanitize custom theme color values on read` | Raw ``, { jar });
+
+ test/groups.js
+ ❯❱ javascript.lang.security.audit.path-traversal.path-join-resolve-
+ traversal.path-join-resolve-traversal
+ ❰❰ Blocking ❱❱
+ Detected possible user input going into a `path.join` or
+ `path.resolve` function. This could possibly lead to a path
+ traversal vulnerability, where the attacker can access
+ arbitrary files stored in the file system. Instead, be sure
+ to sanitize or validate user input first.
+ Details: https://sg.run/OPqk
+
+ 1360┆ const filePath =
+ path.join(nconf.get('upload_path'), 'files',
+ filename);
+
+ test/i18n.js
+ ❯❱ javascript.lang.security.audit.path-traversal.path-join-resolve-
+ traversal.path-join-resolve-traversal
+ ❰❰ Blocking ❱❱
+ Detected possible user input going into a `path.join` or
+ `path.resolve` function. This could possibly lead to a path
+ traversal vulnerability, where the attacker can access
+ arbitrary files stored in the file system. Instead, be sure
+ to sanitize or validate user input first.
+ Details: https://sg.run/OPqk
+
+ 112┆ const translationPath = path.resolve(__dirname,
+ `../public/language/${language}`);
+ ⋮┆----------------------------------------
+ 131┆ const translationPath = path.resolve(__dirname,
+ `../public/language/${language}`);
+
+ test/plugins-installed.js
+ ❯❱ javascript.lang.security.audit.path-traversal.path-join-resolve-
+ traversal.path-join-resolve-traversal
+ ❰❰ Blocking ❱❱
+ Detected possible user input going into a `path.join` or
+ `path.resolve` function. This could possibly lead to a path
+ traversal vulnerability, where the attacker can access
+ arbitrary files stored in the file system. Instead, be sure
+ to sanitize or validate user input first.
+ Details: https://sg.run/OPqk
+
+ 14┆ const pathToTests = path.join(__dirname,
+ '../node_modules', plugin, 'test');
+
+ test/posts/uploads.js
+ ❯❱ javascript.lang.security.audit.path-traversal.path-join-resolve-
+ traversal.path-join-resolve-traversal
+ ❰❰ Blocking ❱❱
+ Detected possible user input going into a `path.join` or
+ `path.resolve` function. This could possibly lead to a path
+ traversal vulnerability, where the attacker can access
+ arbitrary files stored in the file system. Instead, be sure
+ to sanitize or validate user input first.
+ Details: https://sg.run/OPqk
+
+ 24┆ _filenames.forEach(filename => fs.closeSync(fs.open
+ Sync(path.join(nconf.get('upload_path'), 'files',
+ filename), 'w')));
+ ⋮┆----------------------------------------
+ 276┆ const fullPath =
+ path.resolve(nconf.get('upload_path'), 'files',
+ filename);
+
+ test/search-admin.js
+ ❯❱ javascript.lang.security.audit.unknown-value-with-script-tag.unknown-
+ value-with-script-tag
+ ❰❰ Blocking ❱❱
+ Cannot determine what 'search' is and it is used with a
+ '${
- res.locals.preFooter || ''
+ }${res.locals.preFooter || ''
}${results.footer}`;
if (typeof fn !== 'function') {
@@ -210,6 +208,8 @@ module.exports = function (middleware) {
results.user.isEmailConfirmSent = !!results.isEmailConfirmSent;
templateValues.bootswatchSkin = res.locals.config.bootswatchSkin || '';
+ // 'custom' skin uses default CSS with color overrides
+ templateValues.bootswatchCssSkin = templateValues.bootswatchSkin === 'custom' ? '' : templateValues.bootswatchSkin;
templateValues.browserTitle = results.browserTitle;
({
navigation: templateValues.navigation,
diff --git a/src/routes/write/chats.js b/src/routes/write/chats.js
index 7fd2c8e392..892ed362cf 100644
--- a/src/routes/write/chats.js
+++ b/src/routes/write/chats.js
@@ -50,5 +50,8 @@ module.exports = function () {
setupApiRoute(router, 'put', '/:roomId/messages/:mid/pin', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.pin);
setupApiRoute(router, 'delete', '/:roomId/messages/:mid/pin', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.unpin);
+ setupApiRoute(router, 'get', '/:roomId/messages/:mid/reactions', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.getReactions);
+ setupApiRoute(router, 'post', '/:roomId/messages/:mid/reactions', [...middlewares, middleware.assert.room, middleware.assert.message, middleware.checkRequired.bind(null, ['emoji'])], controllers.write.chats.messages.toggleReaction);
+
return router;
};
diff --git a/src/user/settings.js b/src/user/settings.js
index 48b9a8a491..becd2ba2c4 100644
--- a/src/user/settings.js
+++ b/src/user/settings.js
@@ -21,6 +21,14 @@ module.exports = function (User) {
categoryWatchState: 'notwatching',
});
+ const customThemeColorKeys = [
+ 'customThemeColor_headerBg', 'customThemeColor_headerText',
+ 'customThemeColor_bodyBg', 'customThemeColor_bodyText',
+ 'customThemeColor_linkColor',
+ 'customThemeColor_buttonBg', 'customThemeColor_buttonText',
+ ];
+ const validHexColor = val => !val || /^#[0-9A-Fa-f]{6}$/.test(val);
+
User.getSettings = async function (uid) {
if (parseInt(uid, 10) <= 0) {
const isSpider = parseInt(uid, 10) === -1;
@@ -84,6 +92,10 @@ module.exports = function (User) {
settings.scrollToMyPost = parseInt(getSetting(settings, 'scrollToMyPost', 1), 10) === 1;
settings.categoryWatchState = getSetting(settings, 'categoryWatchState', 'notwatching');
+ for (const key of customThemeColorKeys) {
+ settings[key] = validator.escape(String(settings[key] || ''));
+ }
+
const notificationTypes = await notifications.getAllNotificationTypes();
notificationTypes.forEach((notificationType) => {
settings[notificationType] = getSetting(settings, notificationType, 'notification');
@@ -168,6 +180,13 @@ module.exports = function (User) {
chatAllowList: data.chatAllowList,
chatDenyList: data.chatDenyList,
};
+
+ for (const key of customThemeColorKeys) {
+ if (data[key] !== undefined && !validHexColor(data[key])) {
+ throw new Error('[[error:invalid-theme-color]]');
+ }
+ settings[key] = data[key] || '';
+ }
const notificationTypes = await notifications.getAllNotificationTypes();
notificationTypes.forEach((notificationType) => {
if (data[notificationType]) {
diff --git a/src/views/admin/partials/appearance/skins/form-custom-skin.tpl b/src/views/admin/partials/appearance/skins/form-custom-skin.tpl
index 292cf49feb..1000ef2ad3 100644
--- a/src/views/admin/partials/appearance/skins/form-custom-skin.tpl
+++ b/src/views/admin/partials/appearance/skins/form-custom-skin.tpl
@@ -4,8 +4,29 @@
+
\ No newline at end of file
diff --git a/src/views/partials/chats/composer.tpl b/src/views/partials/chats/composer.tpl
index 48d78a89a4..b7c13d88da 100644
--- a/src/views/partials/chats/composer.tpl
+++ b/src/views/partials/chats/composer.tpl
@@ -2,6 +2,9 @@
+
+
+
{{{ if canUpload }}}
@@ -26,4 +29,4 @@
-
\ No newline at end of file
+
diff --git a/src/views/partials/chats/message.tpl b/src/views/partials/chats/message.tpl
index 3d2e0ce122..1f3df28ada 100644
--- a/src/views/partials/chats/message.tpl
+++ b/src/views/partials/chats/message.tpl
@@ -4,6 +4,22 @@
{{{ end }}}
+ {{{ if messages.forwardedMessage }}}
+