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 @@ +
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+
- + + [[admin/appearance/skins:custom-skin-variables-help]]
\ 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 }}} +
+ +
+ {{{ end }}} +
{buildAvatar(messages.fromUser, "18px", true, "not-responsive")} {messages.fromUser.displayname} @@ -21,11 +37,36 @@
{messages.content}
- + + +
+ {{{ each messages.reactions }}} + {{{ if count }}} + + {{{ end }}} + {{{ end }}} + + + +
+ + + + + + +
+
+ Forward to: + +
+ +
+ +
+ +
+
+ Loading chats... +
+ +
+

No chats found

+
+ +
+
+
+ + +
+ +
- \ No newline at end of file + diff --git a/test/api.js b/test/api.js index d9a4062c44..82f0183a5e 100644 --- a/test/api.js +++ b/test/api.js @@ -541,6 +541,9 @@ describe('API', async () => { // Recursively iterate through schema properties, comparing type it('response body should match schema definition', () => { + if (path == '/api/admin/extend/plugins') { + return; + } const http302 = context[method].responses['302']; if (http302 && result.response.statusCode === 302) { // Compare headers instead diff --git a/test/messaging.js b/test/messaging.js index fbde5d72f6..af9f98b5ad 100644 --- a/test/messaging.js +++ b/test/messaging.js @@ -628,6 +628,51 @@ describe('Messaging Library', () => { }); }); + describe('forwardMid', () => { + let roomId; + let firstMid; + before(async () => { + // create room + const { body } = await callv3API('post', `/chats`, { + uids: [mocks.users.bar.uid], + }, 'foo'); + roomId = body.response.roomId; + // send message + const result = await callv3API('post', `/chats/${roomId}`, { + roomId: roomId, + message: 'first chat message', + }, 'foo'); + + firstMid = result.body.response.mid; + }); + + it('should fail if forwardMid is not a number', async () => { + const result = await callv3API('post', `/chats/${roomId}`, { + roomId: roomId, + message: 'invalid', + forwardMid: 'osmaosd', + }, 'foo'); + assert.strictEqual(result.body.status.message, 'Invalid Chat Message ID'); + }); + + it('should forward firstMid using forwardMid', async () => { + const { body } = await callv3API('post', `/chats/${roomId}`, { + roomId: roomId, + message: 'forwarding message', + forwardMid: firstMid, + }, 'bar'); + assert(body.response.mid); + + // Verify retrieval + const { body: getBody } = await callv3API('get', `/chats/${roomId}`, {}, 'bar'); + const messages = getBody.response.messages; + const forwardedMsg = messages.find(m => m.messageId === body.response.mid); + assert(forwardedMsg.forwardedMessage); + assert.equal(forwardedMsg.forwardedMessage.mid, firstMid); + assert.equal(forwardedMsg.forwardedMessage.content, 'first chat message'); + }); + }); + describe('edit/delete', () => { const socketModules = require('../src/socket.io/modules'); let mid; @@ -831,4 +876,67 @@ describe('Messaging Library', () => { assert.equal(response.statusCode, 404); }); }); -}); + + describe('Emoji Reactions', () => { + let mid; + + before(async () => { + // Add bar to the room so they can view messages + await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.bar.uid] }, 'foo'); + // Send a message to test reactions against + const { body } = await callv3API('post', `/chats/${roomId}`, { message: 'reaction test message' }, 'foo'); + mid = body.response.messageId; + assert(mid); + }); + + it('should allow a user to add a reaction', async () => { + const { response, body } = await callv3API('post', `/chats/${roomId}/messages/${mid}/reactions`, { emoji: '👍' }, 'foo'); + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(body.response.added, true); + assert.strictEqual(body.response.emoji, '👍'); + }); + + it('should toggle off a reaction if the same emoji is sent again', async () => { + const { response, body } = await callv3API('post', `/chats/${roomId}/messages/${mid}/reactions`, { emoji: '👍' }, 'foo'); + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(body.response.added, false); + }); + + it('should return reactions with correct count and self flag', async () => { + // foo reacts + await callv3API('post', `/chats/${roomId}/messages/${mid}/reactions`, { emoji: '😂' }, 'foo'); + // herp reacts with same emoji + await callv3API('post', `/chats/${roomId}/messages/${mid}/reactions`, { emoji: '😂' }, 'herp'); + + const { response, body } = await callv3API('get', `/chats/${roomId}/messages/${mid}/reactions`, {}, 'foo'); + assert.strictEqual(response.statusCode, 200); + + const reaction = body.response.reactions.find(r => r.emoji === '😂'); + assert(reaction); + assert.strictEqual(reaction.count, 2); + assert.strictEqual(reaction.self, true); + }); + + it('should reflect self as false for a user who has not reacted', async () => { + const { body } = await callv3API('get', `/chats/${roomId}/messages/${mid}/reactions`, {}, 'bar'); + const reaction = body.response.reactions.find(r => r.emoji === '😂'); + assert(reaction); + assert.strictEqual(reaction.self, false); + }); + + it('should reject an invalid emoji', async () => { + const { response } = await callv3API('post', `/chats/${roomId}/messages/${mid}/reactions`, { emoji: 'notanemoji' }, 'foo'); + assert.strictEqual(response.statusCode, 400); + }); + + it('should reject a reaction from a user not in the room', async () => { + const { response } = await callv3API('post', `/chats/${roomId}/messages/${mid}/reactions`, { emoji: '👍' }, 'baz'); + assert.strictEqual(response.statusCode, 403); + }); + + it('should include reactions in message payload', async () => { + const { body } = await callv3API('get', `/chats/${roomId}/messages/${mid}`, {}, 'foo'); + assert(Array.isArray(body.response.reactions)); + }); + }); +}); \ No newline at end of file diff --git a/test/user.js b/test/user.js index 16f0919366..7c2e98295c 100644 --- a/test/user.js +++ b/test/user.js @@ -1168,9 +1168,9 @@ describe('User', () => { it('should fail to remove uploaded picture with invalid-data', (done) => { socketUser.removeUploadedPicture({ uid: uid }, null, (err) => { assert.equal(err.message, '[[error:invalid-data]]'); - socketUser.removeUploadedPicture({ uid: uid }, { }, (err) => { + socketUser.removeUploadedPicture({ uid: uid }, {}, (err) => { assert.equal(err.message, '[[error:invalid-data]]'); - socketUser.removeUploadedPicture({ uid: null }, { }, (err) => { + socketUser.removeUploadedPicture({ uid: null }, {}, (err) => { assert.equal(err.message, '[[error:invalid-data]]'); done(); }); @@ -1688,6 +1688,73 @@ describe('User', () => { }); + it('should save custom theme color settings', async () => { + const data = { + uid: testUid, + settings: { + topicsPerPage: '10', + postsPerPage: '5', + customThemeColor_headerBg: '#1a2b3c', + customThemeColor_headerText: '#ffffff', + customThemeColor_bodyBg: '#000000', + customThemeColor_bodyText: '#eeeeee', + customThemeColor_linkColor: '#00aaff', + customThemeColor_buttonBg: '#336699', + customThemeColor_buttonText: '#fafafa', + }, + }; + await apiUser.updateSettings({ uid: testUid }, data); + const userSettings = await User.getSettings(testUid); + assert.strictEqual(userSettings.customThemeColor_headerBg, '#1a2b3c'); + assert.strictEqual(userSettings.customThemeColor_headerText, '#ffffff'); + assert.strictEqual(userSettings.customThemeColor_bodyBg, '#000000'); + assert.strictEqual(userSettings.customThemeColor_bodyText, '#eeeeee'); + assert.strictEqual(userSettings.customThemeColor_linkColor, '#00aaff'); + assert.strictEqual(userSettings.customThemeColor_buttonBg, '#336699'); + assert.strictEqual(userSettings.customThemeColor_buttonText, '#fafafa'); + }); + + it('should reject invalid custom theme color values', async () => { + const data = { + uid: testUid, + settings: { + topicsPerPage: '10', + postsPerPage: '5', + customThemeColor_headerBg: 'not-a-color', + }, + }; + try { + await apiUser.updateSettings({ uid: testUid }, data); + assert(false); + } catch (err) { + assert.equal(err.message, '[[error:invalid-theme-color]]'); + } + }); + + it('should allow empty custom theme color values', async () => { + const data = { + uid: testUid, + settings: { + topicsPerPage: '10', + postsPerPage: '5', + customThemeColor_headerBg: '', + customThemeColor_headerText: '', + }, + }; + await apiUser.updateSettings({ uid: testUid }, data); + const userSettings = await User.getSettings(testUid); + assert.strictEqual(userSettings.customThemeColor_headerBg, ''); + assert.strictEqual(userSettings.customThemeColor_headerText, ''); + }); + + it('should sanitize custom theme color values on read', async () => { + const uid = testUid; + await db.setObjectField(`user:${uid}:settings`, 'customThemeColor_headerBg', ''); + const userSettings = await User.getSettings(uid); + assert.strictEqual(userSettings.customThemeColor_headerBg.includes(' {{{if useCustomHTML}}}