diff --git a/README.md b/README.md index 6ef180f..ab5b0d0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +Team Sunglasses: + +Members: Aryan Jain, Eric Due, Emily Xie, Kayo Kuri, Justin Zou + + # ![NodeBB](public/images/sm-card.png) [![Workflow](https://github.com/CMU-313/NodeBB/actions/workflows/test.yaml/badge.svg)](https://github.com/CMU-313/NodeBB/actions/workflows/test.yaml) diff --git a/dump.rdb b/dump.rdb index 0f9b366..aa9f7e2 100644 Binary files a/dump.rdb and b/dump.rdb differ diff --git a/nodebb-theme-harmony/scss/topic.scss b/nodebb-theme-harmony/scss/topic.scss index 4214054..e8c495a 100644 --- a/nodebb-theme-harmony/scss/topic.scss +++ b/nodebb-theme-harmony/scss/topic.scss @@ -86,22 +86,28 @@ body.template-topic { } .admin-star { - width: 60px; - height: 60px; - background-color: gold; - clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); - display: flex; - align-items: center; - justify-content: center; - font-weight: bold; - font-size: 10px; - color: black; - text-transform: uppercase; - text-align: right; - box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.3); - border-radius: 5px; - top: 10px; - left: 10px; + width: 16px; // Tiny but visible + height: 16px; + background: linear-gradient(145deg, #3a8dde, #1e5aa6); // Modern blue gradient + clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, + 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 6px; + text-align: center; + font-weight: bold; + text-shadow: 0px 0px 2px rgba(255, 255, 255, 0.8); + box-shadow: 0px 2px 5px rgba(0, 0, 255, 0.3), inset 0px -2px 3px rgba(0, 0, 0, 0.2); + border-radius: 3px; + position: relative; + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + + &:hover { + transform: scale(1.1); + box-shadow: 0px 3px 8px rgba(0, 0, 255, 0.5); + } } [component="post/replies/container"] { diff --git a/nodebb-theme-harmony/templates/partials/topic/post.tpl b/nodebb-theme-harmony/templates/partials/topic/post.tpl index 3c7fa28..97f378a 100644 --- a/nodebb-theme-harmony/templates/partials/topic/post.tpl +++ b/nodebb-theme-harmony/templates/partials/topic/post.tpl @@ -19,8 +19,8 @@ - {{{ if privileges.isAdminOrMod}}} -
Admin
+ {{{ if posts.user.adminrole }}} +
{{{ end }}}
+ {{{if !posts.anonymous }}} {posts.user.displayname} + {{{ else }}} + Anonymous + {{{ end }}} {{{ each posts.user.selectedGroups }}} {{{ if posts.user.selectedGroups.slug }}} diff --git a/nodebb-theme-harmony/templates/partials/topic/quickreply.tpl b/nodebb-theme-harmony/templates/partials/topic/quickreply.tpl index d26fdea..877d99d 100644 --- a/nodebb-theme-harmony/templates/partials/topic/quickreply.tpl +++ b/nodebb-theme-harmony/templates/partials/topic/quickreply.tpl @@ -17,7 +17,7 @@
- +
diff --git a/public/openapi/components/schemas/Chats.yaml b/public/openapi/components/schemas/Chats.yaml index e7c77c5..be68c50 100644 --- a/public/openapi/components/schemas/Chats.yaml +++ b/public/openapi/components/schemas/Chats.yaml @@ -55,6 +55,8 @@ MessageObject: type: number isOwner: type: boolean + groupTitle: + type: string fromUser: type: object properties: diff --git a/public/openapi/components/schemas/PostObject.yaml b/public/openapi/components/schemas/PostObject.yaml index 8ca14ba..4d8b4fa 100644 --- a/public/openapi/components/schemas/PostObject.yaml +++ b/public/openapi/components/schemas/PostObject.yaml @@ -7,6 +7,8 @@ PostObject: tid: type: number description: A topic identifier + anonymous: + type: number content: type: string uid: diff --git a/public/openapi/components/schemas/TopicObject.yaml b/public/openapi/components/schemas/TopicObject.yaml index ee34558..663e44b 100644 --- a/public/openapi/components/schemas/TopicObject.yaml +++ b/public/openapi/components/schemas/TopicObject.yaml @@ -279,6 +279,8 @@ TopicObjectSlim: tid: type: number description: A topic identifier + adminrole: + type: boolean numThumbs: type: number description: The number of thumbnails associated with this topic diff --git a/public/openapi/read/topic/topic_id.yaml b/public/openapi/read/topic/topic_id.yaml index aff53aa..45df4f6 100644 --- a/public/openapi/read/topic/topic_id.yaml +++ b/public/openapi/read/topic/topic_id.yaml @@ -59,6 +59,8 @@ get: tid: type: number description: A topic identifier + anonymous: + type: number content: type: string timestamp: @@ -91,6 +93,8 @@ get: user: type: object properties: + adminrole: + type: boolean uid: type: number description: A user identifier diff --git a/public/openapi/read/user/userslug/chats/roomid.yaml b/public/openapi/read/user/userslug/chats/roomid.yaml index 6466a40..62999c4 100644 --- a/public/openapi/read/user/userslug/chats/roomid.yaml +++ b/public/openapi/read/user/userslug/chats/roomid.yaml @@ -84,6 +84,9 @@ get: type: number messageId: type: number + groupTitle: + type: string + nullable: true fromUser: type: object properties: diff --git a/public/openapi/write/posts/pid.yaml b/public/openapi/write/posts/pid.yaml index 4cd29c7..1449af6 100644 --- a/public/openapi/write/posts/pid.yaml +++ b/public/openapi/write/posts/pid.yaml @@ -32,6 +32,8 @@ get: tid: type: number description: A topic identifier + anonymous: + type: number content: type: string english: diff --git a/public/openapi/write/posts/pid/isEndorsed.yaml b/public/openapi/write/posts/pid/isEndorsed.yaml index c92b845..0f63ee6 100644 --- a/public/openapi/write/posts/pid/isEndorsed.yaml +++ b/public/openapi/write/posts/pid/isEndorsed.yaml @@ -18,5 +18,8 @@ get: application/json: schema: type: object - properties: {} + properties: + endorsed: + type: boolean + description: Indicates whether the post is endorsed or not diff --git a/public/src/modules/quickreply.js b/public/src/modules/quickreply.js index 55aea0b..d6a7552 100644 --- a/public/src/modules/quickreply.js +++ b/public/src/modules/quickreply.js @@ -58,11 +58,14 @@ define('quickreply', [ } const replyMsg = components.get('topic/quickreply/text').val(); + const anonymousCheckbox = components.get('topic/quickreply/anonymous').get(0).checked; const replyData = { tid: ajaxify.data.tid, handle: undefined, content: replyMsg, + anonymous: anonymousCheckbox, }; + const replyLen = replyMsg.length; if (replyLen < parseInt(config.minimumPostLength, 10)) { return alerts.error('[[error:content-too-short, ' + config.minimumPostLength + ']]'); diff --git a/src/api/posts.js b/src/api/posts.js index 15b554f..0e1389b 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -173,11 +173,11 @@ postsAPI.setPostEndorsement = async function (pid, endorsed = true) { }; postsAPI.getPostEndorsement = async function (pid) { - db.isObjectField(`post:${pid}`, 'endorsed', (err, isField) => { - if (err) { - console.log(err); - } - return isField; + return new Promise((resolve, reject) => { + db.isObjectField(`post:${pid}`, 'endorsed', (err, isField) => { + if (err) reject(err); + else resolve(!!isField); + }); }); }; @@ -411,13 +411,13 @@ async function canSeeVotes(uid, cids, type) { const cidToAllowed = _.zipObject(uniqCids, canRead); const checks = cids.map( (cid, index) => isAdmin || isMod[index] || - ( - cidToAllowed[cid] && ( - meta.config[type] === 'all' || - (meta.config[type] === 'loggedin' && parseInt(uid, 10) > 0) + cidToAllowed[cid] && + ( + meta.config[type] === 'all' || + (meta.config[type] === 'loggedin' && parseInt(uid, 10) > 0) + ) ) - ) ); return isArray ? checks : checks[0]; } diff --git a/src/api/users.js b/src/api/users.js index c4f4add..b883443 100644 --- a/src/api/users.js +++ b/src/api/users.js @@ -180,7 +180,7 @@ usersAPI.follow = async function (caller, data) { toUid: data.uid, }); - const userData = await user.getUserFields(caller.uid, ['username', 'userslug']); + const userData = await user.getUserFields(caller.uid, ['username', 'userslug', 'adminrole']); const { displayname } = userData; const notifObj = await notifications.create({ diff --git a/src/categories/recentreplies.js b/src/categories/recentreplies.js index cb7056b..6f7b3bc 100644 --- a/src/categories/recentreplies.js +++ b/src/categories/recentreplies.js @@ -172,7 +172,7 @@ module.exports = function (Categories) { ]); await batch.processArray(pids, async (pids) => { - const postData = await posts.getPostsFields(pids, ['pid', 'deleted', 'uid', 'timestamp', 'upvotes', 'downvotes']); + const postData = await posts.getPostsFields(pids, ['pid', 'anonymous', 'deleted', 'uid', 'timestamp', 'upvotes', 'downvotes']); const bulkRemove = []; const bulkAdd = []; diff --git a/src/categories/topics.js b/src/categories/topics.js index 12a009e..f4c86d5 100644 --- a/src/categories/topics.js +++ b/src/categories/topics.js @@ -16,6 +16,23 @@ module.exports = function (Categories) { const tids = await Categories.getTopicIds(results); let topicsData = await topics.getTopicsByTids(tids, data.uid); topicsData = await user.blocks.filter(data.uid, topicsData); + + const adminRole = await Promise.all( + topicsData.map(async (topic) => { + const isAdmin = await user.isAdministrator(topic.uid); + if (isAdmin) { + return 'Admin'; + } + return 'user'; + }) + ); + topicsData.forEach((topic, index) => { + if (adminRole[index] === 'Admin') { + topic.adminrole = 'Admin'; + } else { + topic.adminrole = 'User'; + } + }); if (!topicsData.length) { return { topics: [], uid: data.uid }; } diff --git a/src/controllers/category.js b/src/controllers/category.js index c1857c0..24b1133 100644 --- a/src/controllers/category.js +++ b/src/controllers/category.js @@ -38,7 +38,6 @@ function check3(req, utils, cid) { } categoryController.get = async function (req, res, next) { - console.log('Justin Zou'); const cid = req.params.category_id; let currentPage = parseInt(req.query.page, 10) || 1; diff --git a/src/controllers/user.js b/src/controllers/user.js index 6c924ac..c4bc6fe 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -12,7 +12,15 @@ userController.getCurrentUser = async function (req, res) { } const userslug = await user.getUserField(req.uid, 'userslug'); const userData = await accountHelpers.getUserDataByUserSlug(userslug, req.uid, req.query); + const isAdmin = await user.isAdministrator(req.uid); + let role = 'None'; + if (isAdmin) { + role = 'Admin'; + } else { + role = 'User'; + } res.json(userData); + userData.adminrole = role; }; userController.getUserByUID = async function (req, res, next) { diff --git a/src/messaging/data.js b/src/messaging/data.js index dcb77bc..897d15d 100644 --- a/src/messaging/data.js +++ b/src/messaging/data.js @@ -59,17 +59,26 @@ module.exports = function (Messaging) { messages = await user.blocks.filter(uid, 'fromuid', messages); const users = await user.getUsersFields( messages.map(msg => msg && msg.fromuid), - ['uid', 'username', 'userslug', 'picture', 'status', 'banned', 'groupTitle'] + ['uid', 'username', 'userslug', 'picture', 'status', 'banned', 'groupTitle', 'groupTitleArray'] ); messages.forEach((message, index) => { message.fromUser = users[index]; message.fromUser.banned = !!message.fromUser.banned; message.fromUser.deleted = message.fromuid !== message.fromUser.uid && message.fromUser.uid === 0; + message.groupTitle = ''; + if (message.fromUser.groupTitle && message.fromUser.groupTitle.length > 0 && + message.fromUser.groupTitleArray[0] !== undefined) { + const rawTitles = message.fromUser.groupTitleArray.slice(0, 3); + const cleanTitles = rawTitles.map((title) => { + const formattedTitle = title.replace(/"/g, ''); + return formattedTitle.charAt(0).toUpperCase() + formattedTitle.slice(1); + }); + message.groupTitle = cleanTitles.join(', '); + } const self = message.fromuid === parseInt(uid, 10); message.self = self ? 1 : 0; - message.newSet = false; message.roomId = String(message.roomId || roomId); }); diff --git a/src/posts/create.js b/src/posts/create.js index 3305608..81675bc 100644 --- a/src/posts/create.js +++ b/src/posts/create.js @@ -18,6 +18,7 @@ module.exports = function (Posts) { const { uid } = data; const { tid } = data; const content = data.content.toString(); + const anonymous = data.anonymous ? 1 : 0; const timestamp = data.timestamp || Date.now(); const isMain = data.isMain || false; const [english, translatedTxt] = await translationApi.translate(data); @@ -37,6 +38,7 @@ module.exports = function (Posts) { tid: tid, english: english, translation: translatedTxt, + anonymous: anonymous, content: content, timestamp: timestamp, }; diff --git a/src/posts/data.js b/src/posts/data.js index 2ea2c39..bb7a287 100644 --- a/src/posts/data.js +++ b/src/posts/data.js @@ -5,7 +5,7 @@ const plugins = require('../plugins'); const utils = require('../utils'); const intFields = [ - 'uid', 'pid', 'tid', 'deleted', 'timestamp', + 'uid', 'pid', 'tid', 'anonymous', 'deleted', 'timestamp', 'upvotes', 'downvotes', 'deleterUid', 'edited', 'replies', 'bookmarks', ]; diff --git a/src/posts/summary.js b/src/posts/summary.js index 364baad..6c10ded 100644 --- a/src/posts/summary.js +++ b/src/posts/summary.js @@ -20,7 +20,7 @@ module.exports = function (Posts) { options.parse = options.hasOwnProperty('parse') ? options.parse : true; options.extraFields = options.hasOwnProperty('extraFields') ? options.extraFields : []; - const fields = ['pid', 'tid', 'content', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields); + const fields = ['pid', 'tid', 'content', 'anonymous', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields); let posts = await Posts.getPostsFields(pids, fields); posts = posts.filter(Boolean); diff --git a/src/topics/create.js b/src/topics/create.js index 47aade7..5eb7734 100644 --- a/src/topics/create.js +++ b/src/topics/create.js @@ -183,9 +183,10 @@ module.exports = function (Topics) { await guestHandleValid(data); data.content = String(data.content || '').trimEnd(); - data.content = utils.censorBannedMarkdown(data.content); + data.anonymous = data.anonymous ? 1 : 0; + if (!data.fromQueue && !isAdmin) { await user.isReadyToPost(uid, data.cid); Topics.checkContent(data.content); diff --git a/src/topics/posts.js b/src/topics/posts.js index 73eb29b..9215409 100644 --- a/src/topics/posts.js +++ b/src/topics/posts.js @@ -1,4 +1,3 @@ - 'use strict'; const _ = require('lodash'); @@ -108,10 +107,11 @@ module.exports = function (Topics) { return []; } const pids = postData.map(post => post && post.pid); - + let outsideUids = null; async function getPostUserData(field, method) { const uids = _.uniq(postData.filter(p => p && parseInt(p[field], 10) >= 0).map(p => p[field])); const userData = await method(uids); + outsideUids = uids; return _.zipObject(uids, userData); } const [ @@ -124,7 +124,7 @@ module.exports = function (Topics) { posts.hasBookmarked(pids, uid), posts.getVoteStatusByPostIDs(pids, uid), getPostUserData('uid', async uids => await posts.getUserInfoForPosts(uids, uid)), - getPostUserData('editor', async uids => await user.getUsersFields(uids, ['uid', 'username', 'userslug'])), + getPostUserData('editor', async uids => await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'isAdmin'])), getPostReplies(postData, uid), Topics.addParentPosts(postData), ]); @@ -139,8 +139,9 @@ module.exports = function (Topics) { postObj.votes = postObj.votes || 0; postObj.replies = replies[i]; postObj.selfPost = parseInt(uid, 10) > 0 && parseInt(uid, 10) === postObj.uid; - - // Username override for guests, if enabled + outsideUids.forEach((i) => { + userData[String(i)].adminrole = userData[String(i)].groupTitle !== null; + }); if (meta.config.allowGuestHandles && postObj.uid === 0 && postObj.handle) { postObj.user.username = validator.escape(String(postObj.handle)); postObj.user.displayname = postObj.user.username; diff --git a/src/user/posts.js b/src/user/posts.js index c33ebd2..1fbbb12 100644 --- a/src/user/posts.js +++ b/src/user/posts.js @@ -93,7 +93,6 @@ module.exports = function (User) { User.setUserField(postData.uid, 'lastposttime', lastposttime), User.updateLastOnlineTime(postData.uid), ]); - console.log('jzou was here'); }; diff --git a/src/utils.js b/src/utils.js index 2e62507..fcd5f1b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -116,9 +116,9 @@ utils.censorBannedMarkdown = function (content) { return content.replace(bannedWordsRegex, (match) => { if (match.length <= 2) { // Replace single-character words with "*" - return '**'; + return '\\*\\*'; } - return match[0] + '*'.repeat(match.length - 2) + match.slice(-1); + return match[0] + '\\*'.repeat(match.length - 2) + match.slice(-1); }); }; diff --git a/src/views/partials/chats/message.tpl b/src/views/partials/chats/message.tpl index 3f80116..257318f 100644 --- a/src/views/partials/chats/message.tpl +++ b/src/views/partials/chats/message.tpl @@ -7,8 +7,8 @@
{buildAvatar(messages.fromUser, "18px", true, "not-responsive")} {messages.fromUser.displayname} - {{{ if messages.fromUser.groupTitle }}} - ({messages.fromUser.groupTitle}) + {{{ if messages.groupTitle }}} + ({messages.groupTitle}) {{{ end }}} ({messages.fromUser.status}) {{{ if messages.fromUser.banned }}} diff --git a/src/views/partials/data/category.tpl b/src/views/partials/data/category.tpl index 3de7588..9b68a7a 100644 --- a/src/views/partials/data/category.tpl +++ b/src/views/partials/data/category.tpl @@ -1 +1,10 @@ -data-tid="{topics.tid}" data-index="{topics.index}" data-cid="{topics.cid}" itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem" \ No newline at end of file +
+ + {topics.adminrole} + \ No newline at end of file diff --git a/test/messaging.js b/test/messaging.js index 86008e2..f567951 100644 --- a/test/messaging.js +++ b/test/messaging.js @@ -374,6 +374,23 @@ describe('Messaging Library', () => { assert.equal(raw, 'first chat message'); }); + it('should include user group title in chat message if user has a group title', async () => { + const { body } = await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'first chat message' }, 'foo'); + const messageData = body.response; + assert(messageData); + assert(messageData.groupTitle, 'Administrators'); + }); + + it('should not include user groupTitle in chat message if use does not have a group title', async () => { + await Groups.leave(['administrators'], mocks.users.foo.uid); + const { body } = await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'first chat message' }, 'foo'); + const messageData = body.response; + assert(messageData); + assert.strictEqual(messageData.groupTitle, ''); + await Groups.join('administrators', mocks.users.foo.uid); + }); + + it('should fail to send second message due to rate limit', async () => { const oldValue = meta.config.chatMessageDelay; meta.config.chatMessageDelay = 1000; diff --git a/test/topics.js b/test/topics.js index 0c78e3d..ec2cc22 100644 --- a/test/topics.js +++ b/test/topics.js @@ -102,7 +102,7 @@ describe('Topic\'s', () => { content: 'apple banana grape', cid: topic.categoryId, }, (_, result) => { - assert.strictEqual(result.postData.content, 'a***e b****a grape'); + assert.strictEqual(result.postData.content, 'a\\*\\*\\*e b\\*\\*\\*\\*a grape'); meta.config.bannedWords = oldValue; done(); }); @@ -133,7 +133,7 @@ describe('Topic\'s', () => { content: 'op grape', cid: topic.categoryId, }, (_, result) => { - assert.strictEqual(result.postData.content, '** grape'); + assert.strictEqual(result.postData.content, '\\*\\* grape'); meta.config.bannedWords = oldValue; done(); }); @@ -341,6 +341,30 @@ describe('Topic\'s', () => { assert.equal(postData[0].pid, result.pid, 'result should be the reply we added'); }); + it('should create not anonymous replies', async () => { + const result = await topics.reply({ uid: topic.userId, content: 'test not anonymous reply', tid: newTopic.tid, toPid: newPost.pid, anonymous: false }); + assert.ok(result); + + const postData = await apiPosts.getReplies({ uid: 0 }, { pid: newPost.pid }); + assert.ok(postData); + + const anonymousReply = postData.find(reply => reply.pid === result.pid); + assert.ok(anonymousReply, 'the not anonymous reply should be present'); + assert.equal(anonymousReply.anonymous, 0, 'should not be anonymous'); + }); + + it('should create anonymous replies', async () => { + const result = await topics.reply({ uid: topic.userId, content: 'test anonymous reply', tid: newTopic.tid, toPid: newPost.pid, anonymous: true }); + assert.ok(result); + + const postData = await apiPosts.getReplies({ uid: 0 }, { pid: newPost.pid }); + assert.ok(postData); + + const anonymousReply = postData.find(reply => reply.pid === result.pid); + assert.ok(anonymousReply, 'the anonymous reply should be present'); + assert.equal(anonymousReply.anonymous, 1, 'should be anonymous'); + }); + it('should error if pid is not a number', async () => { await assert.rejects( apiPosts.getReplies({ uid: 0 }, { pid: 'abc' }), @@ -436,6 +460,19 @@ describe('Topic\'s', () => { toPid = await posts.getPostField(reply2.pid, 'toPid'); assert.strictEqual(toPid, null); }); + + it('should allow endorsing and unendorsing a reply', async () => { + const result = await topics.post({ uid: fooUid, title: 'nested test', content: 'main post', cid: topic.categoryId }); + const reply1 = await topics.reply({ uid: fooUid, content: 'reply post 1', tid: result.topicData.tid }); + + await apiPosts.setPostEndorsement(reply1.pid, true); + const endorsed = await apiPosts.getPostEndorsement(reply1.pid); + assert(endorsed); + + await apiPosts.setPostEndorsement(reply1.pid, false); + const unendorsed = await apiPosts.getPostEndorsement(reply1.pid); + assert(!unendorsed); + }); }); describe('Get methods', () => {