From d9e774dd770c14d48de45373fa22e0e7ac9ba9f4 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 13 Oct 2020 17:15:52 +0200 Subject: [PATCH] Implement Bailey CMS API (#10739) * Begin refactoring news API to return individual markdown posts * Implement simple bailey CMS * Prevented users with lvl less than 10 from seeing mana * Added in class checks and notification tests * Added getter use * Fixed class check * chore(i18n): update locales * 4.60.2 * remove tests that are no longer needed because we won't be purging private messages (#10670) Ref: this comment from paglias: https://github.com/HabitRPG/habitica/issues/7940#issuecomment-406489506 * remove .only * allow challenge leader/owner to view/join/modify challenge in private group they've left - fixes #9753 (#10606) * rename hasAccess to canJoin for challenges This is so the function won't be used accidentally for other purposes, since hasAccess could be misinterpretted. * add isLeader function for challenges * allow challenge leader to join/modify/end challenge when they're not in the private group it's in * delete duplicate test * clarify title of existing tests * add tests and adjust existing tests to reduce privileges of test users * fix lint errors * remove pointless isLeader check (it's checked in canJoin) * Correct Challenges tooltip in Guild view (#10667) * Fix new party member cannot join pending quest (#10648) * Saved sort selection into local storage for later use - fixes #10432 (#10655) * Saved sort selection into local storage for later use * Updated code to use userLocalManager module * Fix initial position item info when selecting one item after another (fixes #10077) (#10661) * Update lastMouseMoveEvent even when dragging an egg or potion. * Update lastMouseMoveEvent even when dragging a food item. * Refactor/market vue (#10601) * extract inventoryDrawer from market * show scrollbar only if needed * extract featuredItemsHeader / pinUtils * extract pageLayout * extract layoutSection / filterDropdown - fix sortByNumber * rollback sortByNumber order-fix * move equipment lists out of the layout-section (for now) * refactor sellModal * extract checkbox * extract equipment section * extract category row * revert scroll - remove sellModal item template * fix(lint): commas and semis * Created category item component (#10613) * extract filter sidebar * fix gemCount - fix raising the item count if the item wasn't previously owned * fixes #10659 * remove unneeded method * fix typo when importing component * feat(content): Forest Friends Quest Bundle * chore(sprites): compile * chore(i18n): update locales * 4.60.3 * fix(bcrypt): install fork compatible with Node 8 * chore(i18n): update locales * 4.60.4 * add swear words - TRIGGER / CONTENT WARNING: assault, slurs, swearwords, etc * add pinUtils-mixin - fixes #10682 (#10683) * chore(news): Bailey * chore(i18n): update locales * 4.60.5 * Improve rendering banner about sleeping in the inn See #10695 * Display settings in one column * Small Updates (#10701) * small updates * fix client unit test * fix uuid validation * Revert "Small Updates (#10701)" (#10702) This reverts commit dd7fa739613c5e5fd10e14e97465d41b1f38391b. * feat(event): Fall Festival 2018 * chore(sprites): compile * chore(i18n): update locales * 4.61.0 * Move inbox to its own model (#10428) * shared model for chat and inbox * disable inbox schema * inbox: use separate model * remove old code that used group.chat * add back chat field (not used) and remove old tests * remove inbox exclusions when loading user * add GET /api/v3/inbox/messages * add comment * implement DELETE /inbox/messages/:messageid in v4 * implement GET /inbox/messages in v4 and update tests * implement DELETE /api/v4/inbox/clear * fix url * fix doc * update /export/inbox.html * update other data exports * add back messages in user schema * add user.toJSONWithInbox * add compativility until migration is done * more compatibility * fix tojson called twice * add compatibility methods * fix common tests * fix v4 integration tests * v3 get user -> with inbox * start to fix tests * fix v3 integration tests * wip * wip, client use new route * update tests for members/send-private-message * tests for get user in v4 * add tests for DELETE /inbox/messages/:messageId * add tests for DELETE /inbox/clear in v4 * update docs * fix tests * initial migration * fix migration * fix migration * migration fixes * migrate api.enterCouponCode * migrate api.castSpell * migrate reset, reroll, rebirth * add routes to v4 version * fix tests * fixes * api.updateUser * remove .only * get user -> userLib * refactor inbox.vue to work with new data model * fix return message when messaging yourself * wip fix bug with new conversation * wip * fix remaining ui issues * move api.registerLocal, fixes * keep only v3 version of GET /inbox/messages * Fix API early Stat Point allocation (#10680) * Refactor hasClass check to common so it can be used in shared & server-side code * Check that user has selected class before allocating stat points * chore(event): end Ember Hatching Potions * chore(analytics): reenable navigation tracking * update bcrypt * Point achievement modal links to main site (#10709) * Animal ears after death (#10691) * Animal Ears purchasable with Gold if lost in Death * remove ears from pinned items when set is bought * standardise css and error handling for gems and coins * revert accidental new line * fix client tests * Reduce margin-bottom of checklist-item from 10px to -3px. (#10684) * chore(i18n): update locales * 4.61.1 * Position inn banner when window is resized * feat(content): Subscriber Items and Magic Potions * chore(sprites): compile * chore(i18n): update locales * 4.62.0 * Update inn banner handling * Fix banner offset on initial load * Fix minor issues. * Issue: 10660 - Fixed. Changed default to Please Enter A Value (#10718) * Issue: 10660 - Fixed. Changed default to Please Enter A Value * Issue: 10660 - Fixed/revision 2 Changed default to Enter A Value * chore(news): Bailey announcements * chore(i18n): update locales * 4.62.1 * adjust wiki link for usernameInfo string https://github.com/HabitRPG/habitica-private/issues/7#issuecomment-425405425 * raise coverage for tasks api calls (#10029) * - updates a group task - approval is required - updates a group task with checklist * add expect to test the new checklist length * - moves tasks to a specified position out of length * remove unused line * website getter tasks tests * re-add sanitizeUserChallengeTask * change config.json.example variable to be a string not a boolean * fix tests - pick the text / up/down props too * fix test - remove changes on text/up/down - revert sanitize condition - revert sanitization props * chore(i18n): update locales * 4.62.2 * chore(news): Bailey * chore(i18n): update locales * 4.62.3 * inbox: fix avatar display and order * Username announcement (#10729) * Change update username API call The call no longer requires a password and also validates the username. * Implement API call to verify username without setting it * Improve coding style * Apply username verification to registration * Update error messages * Validate display names. * Fix API early Stat Point allocation (#10680) * Refactor hasClass check to common so it can be used in shared & server-side code * Check that user has selected class before allocating stat points * chore(event): end Ember Hatching Potions * chore(analytics): reenable navigation tracking * update bcrypt * Point achievement modal links to main site (#10709) * Animal ears after death (#10691) * Animal Ears purchasable with Gold if lost in Death * remove ears from pinned items when set is bought * standardise css and error handling for gems and coins * revert accidental new line * fix client tests * Reduce margin-bottom of checklist-item from 10px to -3px. (#10684) * chore(i18n): update locales * 4.61.1 * feat(content): Subscriber Items and Magic Potions * chore(sprites): compile * chore(i18n): update locales * 4.62.0 * Display notification for users to confirm their username * fix typo * WIP(usernames): Changes to address #10694 * WIP(usernames): Further changes for #10694 * fix(usernames): don't show spurious headings * Change verify username notification to new version * Improve feedback for invalid usernames * Allow user to set their username again to confirm it * Improve validation display for usernames * Temporarily move display name validation outside of schema * Improve rendering banner about sleeping in the inn See #10695 * Display settings in one column * Position inn banner when window is resized * Update inn banner handling * Fix banner offset on initial load * Fix minor issues. * Issue: 10660 - Fixed. Changed default to Please Enter A Value (#10718) * Issue: 10660 - Fixed. Changed default to Please Enter A Value * Issue: 10660 - Fixed/revision 2 Changed default to Enter A Value * chore(news): Bailey announcements * chore(i18n): update locales * 4.62.1 * adjust wiki link for usernameInfo string https://github.com/HabitRPG/habitica-private/issues/7#issuecomment-425405425 * raise coverage for tasks api calls (#10029) * - updates a group task - approval is required - updates a group task with checklist * add expect to test the new checklist length * - moves tasks to a specified position out of length * remove unused line * website getter tasks tests * re-add sanitizeUserChallengeTask * change config.json.example variable to be a string not a boolean * fix tests - pick the text / up/down props too * fix test - remove changes on text/up/down - revert sanitize condition - revert sanitization props * Change update username API call The call no longer requires a password and also validates the username. * feat(content): Subscriber Items and Magic Potions * Re-add register call * Fix merge issue * Fix issue with setting username * Implement new alert style * Display username confirmation status in settings * Add disclaimer to change username field * validate username in settings * Allow specific fields to be focused when opening site settings * Implement requested changes. * Fix merge issue * Fix failing tests * verify username when users register with username and password * Set ID for change username notification * Disable submit button if username is invalid * Improve username confirmation handling * refactor(settings): address remaining code comments on auth form * Revert "refactor(settings): address remaining code comments on auth form" This reverts commit 9b6609ad646b23d9e3e394c1856f149d9a2d0995. * Social user username (#10620) * Refactored private functions to library * Refactored social login code * Added username to social registration * Changed id library * Added new local auth check * Fixed export error. Fixed password check error * fix(settings): password not available on client * refactor(settings): more sensible placement of methods * chore(migration): script to hand out procgen usernames * fix(migration): don't give EVERYONE new names you doofus * fix(migration): limit data retrieved, be extra careful about updates * fix(migration): use missing field, not migration tag, for query * fix(migration): unused var * fix(usernames): only generate 20 characters * fix(migration): set lowerCaseUsername * fix(lint): comma * fix(lint): comma spacing * chore(i18n): update locales * 4.63.0 * chore(news): Bailey * chore(i18n): update locales * 4.63.1 * fix(usernames): various Reword invalid characters error Correct typo in slur error Remove extraneous Confirm button Reset username field if empty on blur Restore ability to add local auth to social login * fix(auth): account for new username paradigm in add-local flow * fix(auth): alert on successful addLocal * chore(i18n): update locales * 4.63.2 * fix(auth): Don't try to check existing username on new reg * 4.63.3 * feat(content): Armoire and BGs 2018/10 * chore(sprites): compile * fix(passport): use graph API v2.8 * chore(i18n): update locales * 4.64.0 * Begin refactoring news API to return individual markdown posts * Implement simple bailey CMS * remove old news markdown * Correctly display images in bailey modal * Remove need for newStuff migration * Add basic tests * Fix authentication issue * Fix tests * Update news model * add API route to get single post * remove news admin frontend code * fix lint error * Fix merge mixups * Fix lint errors * fix api call * fix lint error * Fix issues caused by merging * remove console log * Improve news display * Correctly update users notifications * Fix date display for news posts * Fix tests * remove old cache file * correctly create date * correctly create promise * Better check for existance. * Improve docs * Fix minor issues * Add method to get latest post * fix lint errors * use correct call for 404 * add comment about old newStuff field * paginate news * Fix lint errors * Remove unnecessary await * Fix broken tests * ... * correct existence check * fix database queries * change approach to cached news posts * fix tests * Change how news posts are cached * Fetch last news post at an interval * Fix typos and other small things * add new permission for modifying bailey posts * add test for ensureNewsPoster * return last news post with legacy api * Fix test * Hopefully fix test * change fields to _id * Fixes * Fixes * fix test * Fixes * make all tests pass * fix lint * id -> _id * _id -> id * remove identical tell me later route from api v4 * fix lint * user model: fix issues with newStuff * improve user#toJSONTransform * fix typo * improve newsPost.js * fix(integration tests): do not return flags.newStuff if it was not selected * fix news controller * server side fixes, start refactoring client * more client fixes * automatically set author * new stuff: show one post per user + drafts * change default border radius for modals to 8px * required fields and defaults * slit news into its own component and fix static page * noNewsPoster: move from i18n to apiError * remove unused strings * fix unit tests * update apidocs * add backward comparibility for flags.newStuff in api v3 * fix integration tests * POST news: make integration test independent of number of posts * api v3 news: render markdown * static new-stuff: add padding and fix when user not logged in * test flags.newStuff * api v3: test setting flags.newStuff on PUT /user * refactor news post cache and add tests * remove new locales file * more resilient tests * more resilient tests * refactor tests for NewsPost.updateLastNewsPost * api v4: fix tests * api v3: fix tests * can set flags.newStuff in api v4 Co-authored-by: Keith Holliday Co-authored-by: Sabe Jones Co-authored-by: Alys Co-authored-by: Matteo Pagliazzi Co-authored-by: Carl Vuorinen Co-authored-by: Rene Cordier Co-authored-by: Forrest Hatfield Co-authored-by: lucubro <88whacko@gmail.com> Co-authored-by: negue Co-authored-by: Alys Co-authored-by: J.D. Sandifer Co-authored-by: Kirsty Co-authored-by: beatscribe Co-authored-by: Phillip Thelen --- .../middlewares/ensureAccessRight.test.js | 23 +- test/api/unit/models/newsPost.js | 138 +++++++++++ test/api/unit/models/user.test.js | 173 +++++++++----- test/api/v3/integration/news/GET-news.test.js | 1 - .../news/POST-news_tell_me_later.test.js | 11 +- test/api/v3/integration/user/PUT-user.test.js | 19 ++ test/api/v4/news/DELETE-news.test.js | 49 ++++ test/api/v4/news/GET-news.test.js | 50 ++++ test/api/v4/news/GET-news_id.test.js | 36 +++ test/api/v4/news/POST-news.test.js | 134 +++++++++++ test/api/v4/news/POST-news_read.test.js | 22 ++ test/api/v4/news/PUT-news_newsId.test.js | 103 ++++++++ website/client/src/assets/scss/modal.scss | 4 + .../src/components/achievements/newStuff.vue | 81 ------- website/client/src/components/news/modal.vue | 58 +++++ .../src/components/news/newsContent.vue | 107 +++++++++ .../client/src/components/notifications.vue | 2 +- .../client/src/components/static/newStuff.vue | 24 +- website/client/src/store/actions/index.js | 2 + website/client/src/store/actions/news.js | 16 ++ website/client/src/store/actions/user.js | 5 - website/common/locales/en/messages.json | 3 +- .../common/script/errors/apiErrorMessages.js | 3 + website/server/controllers/api-v3/news.js | 97 ++++---- website/server/controllers/api-v4/news.js | 224 ++++++++++++++++++ website/server/libs/user/index.js | 16 +- website/server/middlewares/appRoutes.js | 1 + .../server/middlewares/ensureAccessRight.js | 10 + website/server/models/newsPost.js | 95 ++++++++ website/server/models/user/hooks.js | 4 + website/server/models/user/methods.js | 13 +- website/server/models/user/schema.js | 7 +- 32 files changed, 1298 insertions(+), 233 deletions(-) create mode 100644 test/api/unit/models/newsPost.js create mode 100644 test/api/v4/news/DELETE-news.test.js create mode 100644 test/api/v4/news/GET-news.test.js create mode 100644 test/api/v4/news/GET-news_id.test.js create mode 100644 test/api/v4/news/POST-news.test.js create mode 100644 test/api/v4/news/POST-news_read.test.js create mode 100644 test/api/v4/news/PUT-news_newsId.test.js delete mode 100644 website/client/src/components/achievements/newStuff.vue create mode 100644 website/client/src/components/news/modal.vue create mode 100644 website/client/src/components/news/newsContent.vue create mode 100644 website/client/src/store/actions/news.js create mode 100644 website/server/controllers/api-v4/news.js create mode 100644 website/server/models/newsPost.js diff --git a/test/api/unit/middlewares/ensureAccessRight.test.js b/test/api/unit/middlewares/ensureAccessRight.test.js index 30eb84b333..d163ae704d 100644 --- a/test/api/unit/middlewares/ensureAccessRight.test.js +++ b/test/api/unit/middlewares/ensureAccessRight.test.js @@ -5,7 +5,7 @@ import { generateNext, } from '../../../helpers/api-unit.helper'; import i18n from '../../../../website/common/script/i18n'; -import { ensureAdmin, ensureSudo } from '../../../../website/server/middlewares/ensureAccessRight'; +import { ensureAdmin, ensureSudo, ensureNewsPoster } from '../../../../website/server/middlewares/ensureAccessRight'; import { NotAuthorized } from '../../../../website/server/libs/errors'; import apiError from '../../../../website/server/libs/apiError'; @@ -40,6 +40,27 @@ describe('ensure access middlewares', () => { }); }); + context('ensure newsPoster', () => { + it('returns not authorized when user is not a newsPoster', () => { + res.locals = { user: { contributor: { newsPoster: false } } }; + + ensureNewsPoster(req, res, next); + + const calledWith = next.getCall(0).args; + expect(calledWith[0].message).to.equal(apiError('noNewsPosterAccess')); + expect(calledWith[0] instanceof NotAuthorized).to.equal(true); + }); + + it('passes when user is a newsPoster', () => { + res.locals = { user: { contributor: { newsPoster: true } } }; + + ensureNewsPoster(req, res, next); + + expect(next).to.be.calledOnce; + expect(next.args[0]).to.be.empty; + }); + }); + context('ensure sudo', () => { it('returns not authorized when user is not a sudo user', () => { res.locals = { user: { contributor: { sudo: false } } }; diff --git a/test/api/unit/models/newsPost.js b/test/api/unit/models/newsPost.js new file mode 100644 index 0000000000..883ffe193f --- /dev/null +++ b/test/api/unit/models/newsPost.js @@ -0,0 +1,138 @@ +import { v4 } from 'uuid'; +import { model as NewsPost, refreshNewsPost } from '../../../../website/server/models/newsPost'; +import { sleep } from '../../../helpers/api-unit.helper'; + +describe('NewsPost Model', () => { + const publishDate = Number(new Date()); + + // NOTE publishDate is manually increased by +500 for each test + // to make sure it's always in the future from the previous one + // bevause NewsPost.lastNewsPost() is not reset between tests. + // And without a more recent publishDate it wouldn't update + + it('#lastNewsPost', () => { + const lastPost = { _id: v4(), publishDate, published: true }; + NewsPost.updateLastNewsPost(lastPost); + expect(NewsPost.lastNewsPost()).to.equal(lastPost); + }); + + it('#getLastPostFromDatabase', async () => { + const expectedId = v4(); + + await NewsPost.create([ + // more recent but not published + { + _id: v4(), + publishDate: new Date(publishDate + 50), + author: v4(), + published: false, + title: 'Title', + credits: 'credits', + text: 'text', + }, + // expected + { + _id: expectedId, + publishDate, + author: v4(), + published: true, + title: 'Title', + credits: 'credits', + text: 'text', + }, + // published but less recent + { + _id: v4(), + publishDate: new Date(Number(publishDate) - 50), + author: v4(), + published: true, + title: 'Title', + credits: 'credits', + text: 'text', + }, + ]); + + const fetched = await NewsPost.getLastPostFromDatabase(); + expect(fetched._id).to.equal(expectedId); + }); + + context('#updateLastNewsPost', () => { + it('updates the post if new one is more recent and published', () => { + const previousPost = { + _id: v4(), + publishDate: new Date(publishDate + 100), + published: true, + }; + NewsPost.updateLastNewsPost(previousPost); + const newPost = { + _id: v4(), + publishDate: new Date(publishDate + 150), + published: true, + }; + NewsPost.updateLastNewsPost(newPost); + expect(NewsPost.lastNewsPost()._id).to.equal(newPost._id); + }); + + it('does not update the post if new one is from the past', () => { + const previousPost = new NewsPost({ + _id: v4(), publishDate: new Date(publishDate + 200), published: true, + }); + NewsPost.updateLastNewsPost(previousPost); + const newPost = new NewsPost({ + _id: v4(), publishDate: new Date(publishDate + 175), published: true, + }); + NewsPost.updateLastNewsPost(newPost); + expect(NewsPost.lastNewsPost()._id).to.equal(previousPost._id); + }); + + it('does not update the post if new one is not published', () => { + const previousPost = new NewsPost({ + _id: v4(), publishDate: new Date(publishDate + 250), published: true, + }); + NewsPost.updateLastNewsPost(previousPost); + const newPost = new NewsPost({ + _id: v4(), publishDate: new Date(publishDate + 300), published: false, + }); + NewsPost.updateLastNewsPost(newPost); + expect(NewsPost.lastNewsPost()._id).to.equal(previousPost._id); + }); + }); + + context('refreshes NewsPost', () => { + let intervalId; + + beforeEach(async () => { + // Delete all existing posts from the database + await NewsPost.remove(); + }); + + afterEach(() => { + if (intervalId) clearInterval(intervalId); + }); + + it('refreshes the last post at a specific interval', async () => { + await sleep(0.1); // wait 100ms to make sure all previous posts are in the past + const previousPost = { + _id: v4(), publishDate: new Date(), published: true, + }; + NewsPost.updateLastNewsPost(previousPost); + intervalId = refreshNewsPost(50); // refreshes every 50ms + + await sleep(0.1); // wait 100ms to make sure the new post has a more recent publishDate + const newPost = await NewsPost.create({ + _id: v4(), + publishDate: new Date(), + author: v4(), + published: true, + title: 'Title', + credits: 'credits', + text: 'text', + }); + + expect(NewsPost.lastNewsPost()._id).to.equal(previousPost._id); + await sleep(0.15); // wait 150ms + + expect(NewsPost.lastNewsPost()._id).to.equal(newPost._id); + }); + }); +}); diff --git a/test/api/unit/models/user.test.js b/test/api/unit/models/user.test.js index f3345e5478..b3a5200ece 100644 --- a/test/api/unit/models/user.test.js +++ b/test/api/unit/models/user.test.js @@ -1,87 +1,90 @@ import moment from 'moment'; import { model as User } from '../../../../website/server/models/user'; +import { model as NewsPost } from '../../../../website/server/models/newsPost'; import { model as Group } from '../../../../website/server/models/group'; import common from '../../../../website/common'; describe('User Model', () => { - it('keeps user._tmp when calling .toJSON', () => { - const user = new User({ - auth: { - local: { - username: 'username', - lowerCaseUsername: 'username', - email: 'email@email.email', - salt: 'salt', - hashed_password: 'hashed_password', // eslint-disable-line camelcase + describe('.toJSON()', () => { + it('keeps user._tmp when calling .toJSON', () => { + const user = new User({ + auth: { + local: { + username: 'username', + lowerCaseUsername: 'username', + email: 'email@email.email', + salt: 'salt', + hashed_password: 'hashed_password', // eslint-disable-line camelcase + }, }, - }, + }); + + user._tmp = { ok: true }; + user._nonTmp = { ok: true }; + + expect(user._tmp).to.eql({ ok: true }); + expect(user._nonTmp).to.eql({ ok: true }); + + const toObject = user.toObject(); + const toJSON = user.toJSON(); + + expect(toObject).to.not.have.keys('_tmp'); + expect(toObject).to.not.have.keys('_nonTmp'); + + expect(toJSON).to.have.any.key('_tmp'); + expect(toJSON._tmp).to.eql({ ok: true }); + expect(toJSON).to.not.have.keys('_nonTmp'); }); - user._tmp = { ok: true }; - user._nonTmp = { ok: true }; + it('can add computed stats to a JSONified user object', () => { + const user = new User(); + const userToJSON = user.toJSON(); - expect(user._tmp).to.eql({ ok: true }); - expect(user._nonTmp).to.eql({ ok: true }); + expect(userToJSON.stats.maxMP).to.not.exist; + expect(userToJSON.stats.maxHealth).to.not.exist; + expect(userToJSON.stats.toNextLevel).to.not.exist; - const toObject = user.toObject(); - const toJSON = user.toJSON(); + User.addComputedStatsToJSONObj(userToJSON.stats, userToJSON); - expect(toObject).to.not.have.keys('_tmp'); - expect(toObject).to.not.have.keys('_nonTmp'); + expect(userToJSON.stats.maxMP).to.exist; + expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth); + expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl)); + }); - expect(toJSON).to.have.any.key('_tmp'); - expect(toJSON._tmp).to.eql({ ok: true }); - expect(toJSON).to.not.have.keys('_nonTmp'); - }); + it('can transform user object without mongoose helpers', async () => { + const user = new User(); + await user.save(); + const userToJSON = await User.findById(user._id).lean().exec(); - it('can add computed stats to a JSONified user object', () => { - const user = new User(); - const userToJSON = user.toJSON(); + expect(userToJSON.stats.maxMP).to.not.exist; + expect(userToJSON.stats.maxHealth).to.not.exist; + expect(userToJSON.stats.toNextLevel).to.not.exist; + expect(userToJSON.id).to.not.exist; - expect(userToJSON.stats.maxMP).to.not.exist; - expect(userToJSON.stats.maxHealth).to.not.exist; - expect(userToJSON.stats.toNextLevel).to.not.exist; + User.transformJSONUser(userToJSON); - User.addComputedStatsToJSONObj(userToJSON.stats, userToJSON); + expect(userToJSON.id).to.equal(userToJSON._id); + expect(userToJSON.stats.maxMP).to.not.exist; + expect(userToJSON.stats.maxHealth).to.not.exist; + expect(userToJSON.stats.toNextLevel).to.not.exist; + }); - expect(userToJSON.stats.maxMP).to.exist; - expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth); - expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl)); - }); + it('can transform user object without mongoose helpers (including computed stats)', async () => { + const user = new User(); + await user.save(); + const userToJSON = await User.findById(user._id).lean().exec(); - it('can transform user object without mongoose helpers', async () => { - const user = new User(); - await user.save(); - const userToJSON = await User.findById(user._id).lean().exec(); + expect(userToJSON.stats.maxMP).to.not.exist; + expect(userToJSON.stats.maxHealth).to.not.exist; + expect(userToJSON.stats.toNextLevel).to.not.exist; - expect(userToJSON.stats.maxMP).to.not.exist; - expect(userToJSON.stats.maxHealth).to.not.exist; - expect(userToJSON.stats.toNextLevel).to.not.exist; - expect(userToJSON.id).to.not.exist; + User.transformJSONUser(userToJSON, true); - User.transformJSONUser(userToJSON); - - expect(userToJSON.id).to.equal(userToJSON._id); - expect(userToJSON.stats.maxMP).to.not.exist; - expect(userToJSON.stats.maxHealth).to.not.exist; - expect(userToJSON.stats.toNextLevel).to.not.exist; - }); - - it('can transform user object without mongoose helpers (including computed stats)', async () => { - const user = new User(); - await user.save(); - const userToJSON = await User.findById(user._id).lean().exec(); - - expect(userToJSON.stats.maxMP).to.not.exist; - expect(userToJSON.stats.maxHealth).to.not.exist; - expect(userToJSON.stats.toNextLevel).to.not.exist; - - User.transformJSONUser(userToJSON, true); - - expect(userToJSON.id).to.equal(userToJSON._id); - expect(userToJSON.stats.maxMP).to.exist; - expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth); - expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl)); + expect(userToJSON.id).to.equal(userToJSON._id); + expect(userToJSON.stats.maxMP).to.exist; + expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth); + expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl)); + }); }); context('achievements', () => { @@ -827,4 +830,46 @@ describe('User Model', () => { expect(daysMissed).to.eql(0); }); }); + + it('isNewsPoster', async () => { + const user = new User(); + await user.save(); + + expect(user.isNewsPoster()).to.equal(false); + + user.contributor.newsPoster = true; + expect(user.isNewsPoster()).to.equal(true); + }); + + describe('checkNewStuff', () => { + let user; + + beforeEach(() => { + user = new User(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('no last news post', () => { + sandbox.stub(NewsPost, 'lastNewsPost').returns(null); + expect(user.checkNewStuff()).to.equal(false); + expect(user.toJSON().flags.newStuff).to.equal(false); + }); + + it('last news post read', () => { + sandbox.stub(NewsPost, 'lastNewsPost').returns({ _id: '123' }); + user.flags.lastNewStuffRead = '123'; + expect(user.checkNewStuff()).to.equal(false); + expect(user.toJSON().flags.newStuff).to.equal(false); + }); + + it('last news post not read', () => { + sandbox.stub(NewsPost, 'lastNewsPost').returns({ _id: '123' }); + user.flags.lastNewStuffRead = '124'; + expect(user.checkNewStuff()).to.equal(true); + expect(user.toJSON().flags.newStuff).to.equal(true); + }); + }); }); diff --git a/test/api/v3/integration/news/GET-news.test.js b/test/api/v3/integration/news/GET-news.test.js index 8de8afbc05..b47e433a6a 100644 --- a/test/api/v3/integration/news/GET-news.test.js +++ b/test/api/v3/integration/news/GET-news.test.js @@ -4,7 +4,6 @@ import { describe('GET /news', () => { let api; - beforeEach(async () => { api = requester(); }); diff --git a/test/api/v3/integration/news/POST-news_tell_me_later.test.js b/test/api/v3/integration/news/POST-news_tell_me_later.test.js index bd1cdfb147..c5c91f642c 100644 --- a/test/api/v3/integration/news/POST-news_tell_me_later.test.js +++ b/test/api/v3/integration/news/POST-news_tell_me_later.test.js @@ -1,24 +1,27 @@ import { generateUser, } from '../../../../helpers/api-integration/v3'; +import { model as NewsPost } from '../../../../../website/server/models/newsPost'; describe('POST /news/tell-me-later', () => { let user; beforeEach(async () => { - user = await generateUser({ - 'flags.newStuff': true, + NewsPost.updateLastNewsPost({ + _id: '1234', publishDate: new Date(), title: 'Title', published: true, }); + user = await generateUser(); }); it('marks new stuff as read and adds notification', async () => { - expect(user.flags.newStuff).to.equal(true); const initialNotifications = user.notifications.length; await user.post('/news/tell-me-later'); await user.sync(); - expect(user.flags.newStuff).to.equal(false); + expect(user.flags.lastNewStuffRead).to.equal('1234'); + // fetching the user because newStuff is a computed property + expect((await user.get('/user')).flags.newStuff).to.equal(false); expect(user.notifications.length).to.equal(initialNotifications + 1); const notification = user.notifications[user.notifications.length - 1]; diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js index 71e8500263..9025afb326 100644 --- a/test/api/v3/integration/user/PUT-user.test.js +++ b/test/api/v3/integration/user/PUT-user.test.js @@ -3,6 +3,7 @@ import { generateUser, translate as t, } from '../../../../helpers/api-integration/v3'; +import { model as NewsPost } from '../../../../../website/server/models/newsPost'; describe('PUT /user', () => { let user; @@ -101,6 +102,24 @@ describe('PUT /user', () => { message: t('displaynameIssueNewline'), }); }); + + it('can set flags.newStuff to false', async () => { + NewsPost.updateLastNewsPost({ + _id: '1234', publishDate: new Date(), title: 'Title', published: true, + }); + + await user.update({ + 'flags.lastNewStuffRead': '123', + }); + + await user.put('/user', { + 'flags.newStuff': false, + }); + + await user.sync(); + + expect(user.flags.lastNewStuffRead).to.eql('1234'); + }); }); context('Top Level Protected Operations', () => { diff --git a/test/api/v4/news/DELETE-news.test.js b/test/api/v4/news/DELETE-news.test.js new file mode 100644 index 0000000000..211c0d706d --- /dev/null +++ b/test/api/v4/news/DELETE-news.test.js @@ -0,0 +1,49 @@ +import { v4 } from 'uuid'; +import { + generateUser, + translate as t, +} from '../../../helpers/api-integration/v4'; + +describe('DELETE /news/:newsID', () => { + let user; + const newsPost = { + title: 'New Post', + publishDate: new Date(), + published: true, + credits: 'credits', + text: 'news body', + }; + beforeEach(async () => { + user = await generateUser({ + 'contributor.newsPoster': true, + }); + }); + + it('disallows access to non-newsPosters', async () => { + const nonAdminUser = await generateUser({ 'contributor.newsPoster': false }); + await expect(nonAdminUser.del(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: 'You don\'t have news poster access.', + }); + }); + + it('returns an error if the post does not exist', async () => { + await expect(user.del(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('newsPostNotFound'), + }); + }); + + it('deletes news posts', async () => { + const existingPost = await user.post('/news', newsPost); + await user.del(`/news/${existingPost._id}`); + + const returnedPosts = await user.get('/news'); + const deletedPost = returnedPosts.find(returnedPost => returnedPost._id === existingPost._id); + + expect(returnedPosts).is.an('array'); + expect(deletedPost).to.not.exist; + }); +}); diff --git a/test/api/v4/news/GET-news.test.js b/test/api/v4/news/GET-news.test.js new file mode 100644 index 0000000000..64db6d8391 --- /dev/null +++ b/test/api/v4/news/GET-news.test.js @@ -0,0 +1,50 @@ +import { + requester, generateUser, +} from '../../../helpers/api-integration/v4'; + +describe('GET /news', () => { + let api; + const newsPost = { + title: 'New Post', + publishDate: new Date(), + published: true, + credits: 'credits', + text: 'news body', + }; + + before(async () => { + api = requester(); + const user = await generateUser({ + 'contributor.newsPoster': true, + }); + + await Promise.all([ + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + ]); + }); + + it('returns the latest news in json format, does not require authentication, 10 per page', async () => { + const res = await api.get('/news'); + expect(res.length).to.be.equal(10); + expect(res[0].title).to.be.not.empty; + expect(res[0].text).to.be.not.empty; + }); + + it('supports pagination', async () => { + const res = await api.get('/news?page=1'); + expect(res.length).to.be.equal(2); + expect(res[0].title).to.be.not.empty; + expect(res[0].text).to.be.not.empty; + }); +}); diff --git a/test/api/v4/news/GET-news_id.test.js b/test/api/v4/news/GET-news_id.test.js new file mode 100644 index 0000000000..a6b7ed0579 --- /dev/null +++ b/test/api/v4/news/GET-news_id.test.js @@ -0,0 +1,36 @@ +import { v4 } from 'uuid'; +import { + generateUser, + translate as t, +} from '../../../helpers/api-integration/v4'; + +describe('GET /news/:newsID', () => { + let user; + const newsPost = { + title: 'New Post', + publishDate: new Date(), + published: true, + credits: 'credits', + text: 'news body', + }; + beforeEach(async () => { + user = await generateUser({ + 'contributor.newsPoster': true, + }); + }); + + it('returns an error if the post does not exist', async () => { + await expect(user.get(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('newsPostNotFound'), + }); + }); + + it('fetches an existing post', async () => { + const existingPost = await user.post('/news', newsPost); + const fetchedPost = await user.get(`/news/${existingPost._id}`); + + expect(fetchedPost._id).to.equal(existingPost._id); + }); +}); diff --git a/test/api/v4/news/POST-news.test.js b/test/api/v4/news/POST-news.test.js new file mode 100644 index 0000000000..f102616624 --- /dev/null +++ b/test/api/v4/news/POST-news.test.js @@ -0,0 +1,134 @@ +import moment from 'moment'; +import { + generateUser, + sleep, +} from '../../../helpers/api-integration/v4'; +import { model as NewsPost } from '../../../../website/server/models/newsPost'; + +describe('POST /news', () => { + let user; + const newsPost = { + title: 'New Post', + publishDate: new Date(), + published: true, + credits: 'credits', + text: 'news body', + }; + beforeEach(async () => { + user = await generateUser({ + 'contributor.newsPoster': true, + }); + }); + + it('disallows access to non-admins', async () => { + const nonAdminUser = await generateUser({ 'contributor.newsPoster': false }); + await expect(nonAdminUser.post('/news')).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: 'You don\'t have news poster access.', + }); + }); + + it('creates news posts', async () => { + const response = await user.post('/news', newsPost); + + expect(response.title).to.equal(newsPost.title); + expect(response.credits).to.equal(newsPost.credits); + expect(response.text).to.equal(newsPost.text); + expect(response._id).to.exist; + + const res = await user.get('/news'); + expect(res[0]._id).to.equal(response._id); + expect(res[0].title).to.equal(newsPost.title); + expect(res[0].text).to.equal(newsPost.text); + }); + + context('calls updateLastNewsPost', () => { + beforeEach(async () => { + await NewsPost.remove({ }); + }); + + afterEach(async () => { + newsPost.publishDate = new Date(); + newsPost.published = true; + }); + + it('new post is published and the most recent one', async () => { + newsPost.publishDate = new Date(); + const newPost = await user.post('/news', newsPost); + await sleep(0.05); + expect(NewsPost.lastNewsPost()._id).to.equal(newPost._id); + }); + + it('new post is not published', async () => { + newsPost.published = false; + const newPost = await user.post('/news', newsPost); + await sleep(0.05); + expect(NewsPost.lastNewsPost()._id).to.not.equal(newPost._id); + }); + + it('new post is published but in the future', async () => { + newsPost.publishDate = moment().add({ days: 1 }).toDate(); + const newPost = await user.post('/news', newsPost); + await sleep(0.05); + expect(NewsPost.lastNewsPost()._id).to.not.equal(newPost._id); + }); + + it('new post is published but not the most recent one', async () => { + const oldPost = await user.post('/news', newsPost); + newsPost.publishDate = moment().subtract({ days: 1 }).toDate(); + await user.post('/news', newsPost); + await sleep(0.05); + expect(NewsPost.lastNewsPost()._id).to.equal(oldPost._id); + }); + }); + + it('sets default fields', async () => { + const response = await user.post('/news', { + title: 'A post', + credits: 'Credits', + text: 'Text', + }); + + expect(response.published).to.equal(false); + expect(response.publishDate).to.exist; + expect(response.author).to.equal(user._id); + expect(response.createdAt).to.exist; + expect(response.updatedAt).to.exist; + }); + + context('required fields', () => { + it('title', async () => { + await expect(user.post('/news', { + text: 'Text', + credits: 'Credits', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'NewsPost validation failed', + }); + }); + + it('credits', async () => { + await expect(user.post('/news', { + text: 'Text', + title: 'Title', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'NewsPost validation failed', + }); + }); + + it('text', async () => { + await expect(user.post('/news', { + credits: 'credits', + title: 'Title', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'NewsPost validation failed', + }); + }); + }); +}); diff --git a/test/api/v4/news/POST-news_read.test.js b/test/api/v4/news/POST-news_read.test.js new file mode 100644 index 0000000000..f82c2a7fb1 --- /dev/null +++ b/test/api/v4/news/POST-news_read.test.js @@ -0,0 +1,22 @@ +import { + generateUser, +} from '../../../helpers/api-integration/v4'; +import { model as NewsPost } from '../../../../website/server/models/newsPost'; + +describe('POST /news/read', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('marks new stuff as read', async () => { + NewsPost.updateLastNewsPost({ _id: '1234', publishDate: new Date(), published: true }); + await user.post('/news/read'); + await user.sync(); + + expect(user.flags.lastNewStuffRead).to.equal('1234'); + // fetching the user because newStuff is a computed property + expect((await user.get('/user')).flags.newStuff).to.equal(false); + }); +}); diff --git a/test/api/v4/news/PUT-news_newsId.test.js b/test/api/v4/news/PUT-news_newsId.test.js new file mode 100644 index 0000000000..6301b94aa3 --- /dev/null +++ b/test/api/v4/news/PUT-news_newsId.test.js @@ -0,0 +1,103 @@ +import { v4 } from 'uuid'; +import { + generateUser, + translate as t, + sleep, +} from '../../../helpers/api-integration/v4'; +import { model as NewsPost } from '../../../../website/server/models/newsPost'; + +describe('PUT /news/:newsID', () => { + let user; + const newsPost = { + title: 'New Post', + publishDate: new Date(), + published: true, + credits: 'credits', + text: 'news body', + }; + beforeEach(async () => { + user = await generateUser({ + 'contributor.newsPoster': true, + }); + }); + + it('disallows access to non-admins', async () => { + const nonAdminUser = await generateUser({ 'contributor.newsPoster': false }); + await expect(nonAdminUser.put('/news/1234')).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: 'You don\'t have news poster access.', + }); + }); + + it('returns an error if the post does not exist', async () => { + await expect(user.put(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('newsPostNotFound'), + }); + }); + + it('updates existing news posts', async () => { + const existingPost = await user.post('/news', newsPost); + const updatedPost = await user.put(`/news/${existingPost._id}`, { + title: 'Changed Title', + }); + + expect(updatedPost.title).to.equal('Changed Title'); + expect(updatedPost.credits).to.equal(existingPost.credits); + expect(updatedPost.text).to.equal(existingPost.text); + expect(updatedPost.published).to.equal(existingPost.published); + expect(updatedPost._id).to.equal(existingPost._id); + }); + + context('calls updateLastNewsPost', () => { + beforeEach(async () => { + await NewsPost.remove({ }); + }); + + it('updates post data', async () => { + const existingPost = await user.post('/news', { ...newsPost, publishDate: new Date() }); + const updatedPost = await user.put(`/news/${existingPost._id}`, { + title: 'Changed Title', + }); + await sleep(0.05); + + expect(NewsPost.lastNewsPost().title).to.equal(updatedPost.title); + }); + + it('updated post is not published', async () => { + const oldPost = await user.post('/news', { ...newsPost, publishDate: new Date() }); + const newUnpublished = await user.post('/news', { ...newsPost, published: false }); + await user.put(`/news/${newUnpublished._id}`, { + title: 'Changed Title', + }); + await sleep(0.05); + + expect(NewsPost.lastNewsPost()._id).to.equal(oldPost._id); + }); + + it('updated post is published', async () => { + await user.post('/news', { ...newsPost, publishDate: new Date() }); + const newUnpublished = await user.post('/news', { ...newsPost, published: false, publishDate: new Date() }); + await user.put(`/news/${newUnpublished._id}`, { + publishDate: new Date(), + published: true, + }); + await sleep(0.05); + + expect(NewsPost.lastNewsPost()._id).to.equal(newUnpublished._id); + }); + + it('updated post publishDate is in future', async () => { + const oldPost = await user.post('/news', { ...newsPost, publishDate: new Date() }); + const newUnpublished = await user.post('/news', newsPost); + await user.put(`/news/${newUnpublished._id}`, { + publishDate: Date.now() + 50000, + }); + await sleep(0.05); + + expect(NewsPost.lastNewsPost()._id).to.equal(oldPost._id); + }); + }); +}); diff --git a/website/client/src/assets/scss/modal.scss b/website/client/src/assets/scss/modal.scss index baec4afd79..15fae725c4 100644 --- a/website/client/src/assets/scss/modal.scss +++ b/website/client/src/assets/scss/modal.scss @@ -5,6 +5,10 @@ padding-left: 0px !important; } +.modal-content { + border-radius: 8px; +} + .modal-dialog { margin: 3rem auto 3rem; width: auto; diff --git a/website/client/src/components/achievements/newStuff.vue b/website/client/src/components/achievements/newStuff.vue deleted file mode 100644 index 53c427a383..0000000000 --- a/website/client/src/components/achievements/newStuff.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - - - diff --git a/website/client/src/components/news/modal.vue b/website/client/src/components/news/modal.vue new file mode 100644 index 0000000000..072e941587 --- /dev/null +++ b/website/client/src/components/news/modal.vue @@ -0,0 +1,58 @@ + + + diff --git a/website/client/src/components/news/newsContent.vue b/website/client/src/components/news/newsContent.vue new file mode 100644 index 0000000000..9dfa240463 --- /dev/null +++ b/website/client/src/components/news/newsContent.vue @@ -0,0 +1,107 @@ + + + + + + + diff --git a/website/client/src/components/notifications.vue b/website/client/src/components/notifications.vue index aed0156cfa..206ebabd3d 100644 --- a/website/client/src/components/notifications.vue +++ b/website/client/src/components/notifications.vue @@ -118,7 +118,7 @@ import notifications from '@/mixins/notifications'; import guide from '@/mixins/guide'; import yesterdailyModal from './tasks/yesterdailyModal'; -import newStuff from './achievements/newStuff'; +import newStuff from './news/modal'; import death from './achievements/death'; import lowHealth from './achievements/lowHealth'; import levelUp from './achievements/levelUp'; diff --git a/website/client/src/components/static/newStuff.vue b/website/client/src/components/static/newStuff.vue index 6a0c6d023d..772cbfa0ff 100644 --- a/website/client/src/components/static/newStuff.vue +++ b/website/client/src/components/static/newStuff.vue @@ -1,26 +1,18 @@ - - diff --git a/website/client/src/store/actions/index.js b/website/client/src/store/actions/index.js index 539f71c1e2..947be42878 100644 --- a/website/client/src/store/actions/index.js +++ b/website/client/src/store/actions/index.js @@ -16,6 +16,7 @@ import * as hall from './hall'; import * as shops from './shops'; import * as snackbars from './snackbars'; import * as worldState from './worldState'; +import * as news from './news'; // Actions should be named as 'actionName' and can be accessed as 'namespace:actionName' // Example: fetch in user.js -> 'user:fetch' @@ -37,6 +38,7 @@ const actions = flattenAndNamespace({ shops, snackbars, worldState, + news, }); export default actions; diff --git a/website/client/src/store/actions/news.js b/website/client/src/store/actions/news.js new file mode 100644 index 0000000000..594fca78b9 --- /dev/null +++ b/website/client/src/store/actions/news.js @@ -0,0 +1,16 @@ +import axios from 'axios'; + +export async function markAsRead (store) { + store.state.user.data.flags.newStuff = false; + return axios.post('/api/v4/news/read'); +} + +export function remindMeLater (store) { + store.state.user.data.flags.newStuff = false; + return axios.post('/api/v4/news/tell-me-later'); +} + +export async function fetch () { + const response = await axios.get('/api/v4/news'); + return response.data.data; +} diff --git a/website/client/src/store/actions/user.js b/website/client/src/store/actions/user.js index fa1de5dcc7..0d4327366b 100644 --- a/website/client/src/store/actions/user.js +++ b/website/client/src/store/actions/user.js @@ -131,11 +131,6 @@ export async function openMysteryItem (store) { return axios.post('/api/v4/user/open-mystery-item'); } -export function newStuffLater (store) { - store.state.user.data.flags.newStuff = false; - return axios.post('/api/v4/news/tell-me-later'); -} - export async function rebirth () { const result = await axios.post('/api/v4/user/rebirth'); diff --git a/website/common/locales/en/messages.json b/website/common/locales/en/messages.json index 2006b65dda..b195e812aa 100644 --- a/website/common/locales/en/messages.json +++ b/website/common/locales/en/messages.json @@ -53,5 +53,6 @@ "messageDeletedUser": "Sorry, this user has deleted their account.", "messageMissingDisplayName": "Missing display name.", "reportedMessage": "You have reported this message to moderators.", - "canDeleteNow": "You can now delete the message if you wish." + "canDeleteNow": "You can now delete the message if you wish.", + "newsPostNotFound": "News Post not found or you don't have access." } diff --git a/website/common/script/errors/apiErrorMessages.js b/website/common/script/errors/apiErrorMessages.js index e9b3ca0be0..2d45de4e3f 100644 --- a/website/common/script/errors/apiErrorMessages.js +++ b/website/common/script/errors/apiErrorMessages.js @@ -27,6 +27,9 @@ export default { missingSubKey: 'Missing "req.query.sub"', invalidGemsBlock: 'The supplied gemsBlock does not exists', + postIdRequired: '"postId" must be a valid UUID.', + noNewsPosterAccess: 'You don\'t have news poster access.', + ipAddressBlocked: 'Your access to Habitica has been blocked. This may be due to a breach of our Terms of Service or for other reasons. For details or to ask to be unblocked, please email admin@habitica.com or ask your parent or guardian to email them. Include your Habitica @Username or User Id in the email if you know it.', clientRateLimited: 'This User ID or IP address has been rate limited due to an excess amount of requests to the Habitica API v3. More info can be found in the response headers and at https://habitica.fandom.com/wiki/Guidance_for_Comrades#Rules_for_Third-Party_Tools under the section Rate Limiting.', diff --git a/website/server/controllers/api-v3/news.js b/website/server/controllers/api-v3/news.js index 28c6727d9e..18a153550d 100644 --- a/website/server/controllers/api-v3/news.js +++ b/website/server/controllers/api-v3/news.js @@ -1,10 +1,9 @@ +import md from 'habitica-markdown'; import { authWithHeaders } from '../../middlewares/auth'; +import { model as NewsPost } from '../../models/newsPost'; const api = {}; -// @TODO export this const, cannot export it from here because only routes are exported from -// controllers -const LAST_ANNOUNCEMENT_TITLE = 'NEW BACKGROUNDS AND ARMOIRE ITEMS! PLUS, SPOOKY SPARKLES IN THE SEASONAL SHOP!'; const worldDmg = { // @TODO bailey: false, }; @@ -24,51 +23,42 @@ api.getNews = { async handler (req, res) { const baileyClass = worldDmg.bailey ? 'npc_bailey_broken' : 'npc_bailey'; - res.status(200).send({ - html: ` -
-
-
-
-

${res.t('newStuff')}

-

10/1/2020 - ${LAST_ANNOUNCEMENT_TITLE}

+ const lastNewsPost = NewsPost.lastNewsPost(); + if (lastNewsPost) { + res.status(200).send({ + html: ` +
+
+
+
+

${res.t('newStuff')}

+

${lastNewsPost.title.toUpperCase()}

+
+
+
+

+ ${md.unsafeHTMLRender(lastNewsPost.text)} +

+
+ by ${lastNewsPost.credits}
-
-
-

October Backgrounds and Armoire Items!

-

- We’ve added three new backgrounds to the Background Shop! Now your avatar can dare to - visit a Haunted Forest, brave the Spooky Scarecrow Field, or bask in the glow of the - Crescent Moon. Check them out under User Icon > Backgrounds on web and Menu > Inventory > - Customize Avatar on mobile! -

-

- Plus, there’s new Gold-purchasable equipment in the Enchanted Armoire, including the - Autumn Enchanter Set. Better work hard on your real-life tasks to earn all the pieces! - Enjoy :) -

-
by AnnDeLune and SabreCat
-
-

Spooky Sparkles in Seasonal Shop

-

- There's a new Gold-purchasable item in the Seasonal Shop: - Spooky Sparkles! Buy some and then cast it on your friends. I wonder what it will do? -

-

- If you have Spooky Sparkles cast on you, you will receive the "Alarming Friends" badge! - Don't worry, any mysterious effects will wear off the next day.... or you can cancel them - early by buying an Opaque Potion! -

-

- While you're at it, be sure to check out all the other items in the Seasonal Shop! There - are lots of equipment items from the previous Fall Festivals. The Seasonal Shop will only - be open until October 31st, so stock up now. -

-
by Lemoness and SabreCat
-
- `, - }); + `, + }); + } else { + res.status(200).send({ + html: ` +
+
+
+
+

${res.t('newStuff')}

+
+
+
+ `, + }); + } }, }; @@ -79,7 +69,6 @@ api.getNews = { * Prevent this specific Bailey message from appearing automatically. * @apiGroup News * - * * @apiSuccess {Object} data An empty Object * */ @@ -90,13 +79,17 @@ api.tellMeLaterNews = { async handler (req, res) { const { user } = res.locals; - user.flags.newStuff = false; + const lastNewsPost = NewsPost.lastNewsPost(); + if (lastNewsPost) { + user.flags.lastNewStuffRead = lastNewsPost._id; - const existingNotificationIndex = user.notifications.findIndex(n => n && n.type === 'NEW_STUFF'); - if (existingNotificationIndex !== -1) user.notifications.splice(existingNotificationIndex, 1); - user.addNotification('NEW_STUFF', { title: LAST_ANNOUNCEMENT_TITLE }, true); // seen by default + const existingNotificationIndex = user.notifications.findIndex(n => n && n.type === 'NEW_STUFF'); + if (existingNotificationIndex !== -1) user.notifications.splice(existingNotificationIndex, 1); + user.addNotification('NEW_STUFF', { title: lastNewsPost.title.toUpperCase() }, true); // seen by default + + await user.save(); + } - await user.save(); res.respond(200, {}); }, }; diff --git a/website/server/controllers/api-v4/news.js b/website/server/controllers/api-v4/news.js new file mode 100644 index 0000000000..49a3ef5680 --- /dev/null +++ b/website/server/controllers/api-v4/news.js @@ -0,0 +1,224 @@ +import _ from 'lodash'; +import { authWithHeaders } from '../../middlewares/auth'; +import apiError from '../../libs/apiError'; +import { model as NewsPost } from '../../models/newsPost'; +import { ensureNewsPoster } from '../../middlewares/ensureAccessRight'; +import { + NotFound, +} from '../../libs/errors'; + +const api = {}; + +/** + * @apiDefine postIdRequired + * @apiError (400) {BadRequest} postIdRequired A postId is required + */ + +/** + * @apiDefine NewsPostNotFound + * @apiError (404) {NotFound} NewsPostNotFound The specified news post could not be found. + */ + +/** + * @api {get} /api/v4/news Get latest Bailey announcements + * @apiName GetNews + * @apiGroup News + * + * @apiParam (Query) {Number} [page] This parameter can be used to specify the page number + * (the initial page is number 0 and not required). + * + * @apiSuccess {Array} Data An array of Bailey posts + * + */ +api.getNews = { + method: 'GET', + url: '/news', + middlewares: [authWithHeaders({ + optional: true, + })], + noLanguage: true, + async handler (req, res) { + const { user } = res.locals; + const { page } = req.query; + + let isNewsPoster = false; + if (user) { + isNewsPoster = user.isNewsPoster(); + } + + const results = await NewsPost.getNews(isNewsPoster, { page }); + res.respond(200, results); + }, +}; + +/** + * @api {post} /api/v4/news Create a new news post + * @apiName CreateNewsPost + * @apiGroup News + * + * @apiSuccess {Object} data The created news post (See /website/server/models/newsPost.js) + * + * @apiSuccessExample {json} Post: + * HTTP/1.1 200 OK + * { + * "title": "News Title", + * ... + * } + * + * @apiPermission NewsPoster + */ +api.createNews = { + method: 'POST', + url: '/news', + middlewares: [authWithHeaders(), ensureNewsPoster], + async handler (req, res) { + const newsPost = new NewsPost(NewsPost.sanitize(req.body)); + newsPost.author = res.locals.user._id; + await newsPost.save(); + + res.respond(201, newsPost); + + NewsPost.updateLastNewsPost(newsPost); + }, +}; + +/** + * @api {get} /api/v4/news/:postId Get a specific news post + * @apiName GetNewsPost + * @apiGroup News + * + * @apiParam (Path) {String} postId The posts _id + * + * @apiSuccess {Object} data The news post (See /website/server/models/newsPost.js) + * + * @apiSuccessExample {json} Post: + * HTTP/1.1 200 OK + * { + * "title": "News Title", + * ... + * } + * + * @apiUse postIdRequired + * @apiUse NewsPostNotFound + * + */ +api.getPost = { + method: 'GET', + url: '/news/:postId', + middlewares: [authWithHeaders({ + optional: true, + })], + noLanguage: true, + async handler (req, res) { + req.checkParams('postId', apiError('postIdRequired')).notEmpty().isUUID(); + const { user } = res.locals; + + const newsPost = await NewsPost.findById(req.params.postId).exec(); + if (!newsPost || (!user.isNewsPoster() && !newsPost.isPublished)) { + throw new NotFound(res.t('newsPostNotFound')); + } else { + res.respond(200, newsPost); + } + }, +}; + +/** + * @api {put} /api/v4/news/:postId Update a news post + * @apiName UpdateNewsPost + * @apiGroup News + * + * @apiParam (Path) {String} postId The posts _id + * + * @apiSuccess {Object} data The updated news post (See /website/server/models/newsPost.js) + * + * @apiSuccessExample {json} Post: + * HTTP/1.1 200 OK + * { + * "title": "News Title", + * ... + * } + * + * @apiUse postIdRequired + * @apiUse NewsPostNotFound + * + * @apiPermission NewsPoster + */ +api.updateNews = { + method: 'PUT', + url: '/news/:postId', + middlewares: [authWithHeaders(), ensureNewsPoster], + async handler (req, res) { + req.checkParams('postId', apiError('postIdRequired')).notEmpty().isUUID(); + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const newsPost = await NewsPost.findById(req.params.postId).exec(); + if (!newsPost) throw new NotFound(res.t('newsPostNotFound')); + + _.merge(newsPost, NewsPost.sanitize(req.body)); + const savedPost = await newsPost.save(); + + res.respond(200, savedPost); + + NewsPost.updateLastNewsPost(newsPost); + }, +}; + +/** + * @api {delete} /api/v4/news/:postId Delete a news post + * @apiName DeleteNewsPost + * @apiGroup News + * + * @apiParam (Path) {String} postId The posts _id + * + * @apiSuccess {Object} data An empty object + * + * @apiUse postIdRequired + * @apiUse NewsPostNotFound + * + * @apiPermission NewsPoster + */ +api.deleteNews = { + method: 'DELETE', + url: '/news/:postId', + middlewares: [authWithHeaders(), ensureNewsPoster], + async handler (req, res) { + req.checkParams('postId', apiError('postIdRequired')).notEmpty().isUUID(); + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const newsPost = await NewsPost.findById(req.params.postId).exec(); + if (!newsPost) throw new NotFound(res.t('newsPostNotFound')); + + await NewsPost.remove({ _id: req.params.postId }).exec(); + + res.respond(200, {}); + }, +}; + +/** + * @api {post} /api/v4/news/read Mark the latest Bailey announcement as read + * @apiName MarkNewsRead + * @apiGroup News + * + * @apiSuccess {Object} data An empty Object + */ +api.markNewsRead = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/news/read', + async handler (req, res) { + const { user } = res.locals; + + const lastNewsPost = NewsPost.lastNewsPost(); + if (lastNewsPost) { + user.flags.lastNewStuffRead = lastNewsPost._id; + + await user.save(); + } + + res.respond(200, {}); + }, +}; + +export default api; diff --git a/website/server/libs/user/index.js b/website/server/libs/user/index.js index 26790993ec..7ab2e44ef3 100644 --- a/website/server/libs/user/index.js +++ b/website/server/libs/user/index.js @@ -6,6 +6,7 @@ import { NotAuthorized, } from '../errors'; import { model as User, schema as UserSchema } from '../../models/user'; +import { model as NewsPost } from '../../models/newsPost'; import { nameContainsSlur, nameContainsNewline } from './validation'; export async function get (req, res, { isV3 = false }) { @@ -42,7 +43,6 @@ const updatablePaths = [ 'flags.welcomed', 'flags.cardReceived', 'flags.warnedLowHealth', - 'flags.newStuff', 'achievements', @@ -55,7 +55,6 @@ const updatablePaths = [ 'profile', 'stats', 'inbox.optOut', - 'tags', ]; // This tells us for which paths users can call `PUT /user`. @@ -122,9 +121,7 @@ export async function update (req, res, { isV3 = false }) { throw new NotAuthorized(res.t('mustPurchaseToSet', { val, key })); } - if (acceptablePUTPaths[key] && key !== 'tags') { - _.set(user, key, val); - } else if (key === 'tags') { + if (key === 'tags') { if (!Array.isArray(val)) throw new BadRequest('mustBeArray'); const removedTagsIds = []; @@ -161,6 +158,15 @@ export async function update (req, res, { isV3 = false }) { tags: tagId, }, }, { multi: true }).exec()); + } else if (key === 'flags.newStuff' && val === false) { + // flags.newStuff was removed from the user schema and is only returned for compatibility + // reasons but we're keeping the ability to set it in API v3 + const lastNewsPost = NewsPost.lastNewsPost(); + if (lastNewsPost) { + user.flags.lastNewStuffRead = lastNewsPost._id; + } + } else if (acceptablePUTPaths[key]) { + _.set(user, key, val); } else { throw new NotAuthorized(res.t('messageUserOperationProtected', { operation: key })); } diff --git a/website/server/middlewares/appRoutes.js b/website/server/middlewares/appRoutes.js index 646985c3a5..19931eab6b 100644 --- a/website/server/middlewares/appRoutes.js +++ b/website/server/middlewares/appRoutes.js @@ -36,6 +36,7 @@ app.use('/api/v3', rateLimiter, v3Router); const v4RouterOverrides = [ // 'GET-/status', Example to override the GET /status api call 'POST-/user/auth/local/register', + 'GET-/news', 'GET-/user', 'PUT-/user', 'POST-/user/class/cast/:spellId', diff --git a/website/server/middlewares/ensureAccessRight.js b/website/server/middlewares/ensureAccessRight.js index a16da0d5f7..12b2293a68 100644 --- a/website/server/middlewares/ensureAccessRight.js +++ b/website/server/middlewares/ensureAccessRight.js @@ -13,6 +13,16 @@ export function ensureAdmin (req, res, next) { return next(); } +export function ensureNewsPoster (req, res, next) { + const { user } = res.locals; + + if (!user.contributor.newsPoster) { + return next(new NotAuthorized(apiError('noNewsPosterAccess'))); + } + + return next(); +} + export function ensureSudo (req, res, next) { const { user } = res.locals; diff --git a/website/server/models/newsPost.js b/website/server/models/newsPost.js new file mode 100644 index 0000000000..3f1ccb236a --- /dev/null +++ b/website/server/models/newsPost.js @@ -0,0 +1,95 @@ +import mongoose from 'mongoose'; +import baseModel from '../libs/baseModel'; +import logger from '../libs/logger'; + +const { Schema } = mongoose; +const POSTS_PER_PAGE = 10; + +export const schema = new Schema({ + title: { $type: String, required: true }, + text: { $type: String, required: true }, + credits: { $type: String, required: true }, + author: { $type: String, ref: 'User', required: true }, + publishDate: { $type: Date, required: true, default: Date.now }, + published: { $type: Boolean, required: true, default: false }, +}, { + strict: true, + minimize: false, // So empty objects are returned + typeKey: '$type', // So that we can use fields named `type` +}); + +schema.plugin(baseModel, { + noSet: ['_id', 'author'], + timestamps: true, +}); + +schema.statics.getNews = async function getNews (isAdmin, options = { page: 0 }) { + let query; + if (!isAdmin) { + query = this.find({ + published: true, + publishDate: { $lte: new Date() }, + }); + } else { + query = this.find(); + } + + let page = 0; + if (typeof options.page !== 'undefined') { + page = options.page; + } + + return query + .sort({ publishDate: -1 }) + .limit(POSTS_PER_PAGE) + .skip(POSTS_PER_PAGE * Number(page)) + .exec(); +}; + +const NEWS_CACHE_TIME = 5 * 60 * 1000; + +let cachedLastNewsPost = null; + +schema.statics.getLastPostFromDatabase = async function getLastPostFromDatabase () { + const post = await this.findOne({ + published: true, + publishDate: { $lte: new Date() }, + }).sort({ publishDate: -1 }).exec(); + + return post; +}; + +schema.statics.lastNewsPost = function lastNewsPost () { + return cachedLastNewsPost; +}; + +schema.statics.updateLastNewsPost = function updateLastNewsPost (newPost) { + const isSame = !cachedLastNewsPost ? false : cachedLastNewsPost._id === newPost._id; + const isPublished = newPost.published; + const isNewer = !cachedLastNewsPost ? true : cachedLastNewsPost.publishDate < newPost.publishDate; + const isInFuture = newPost.publishDate > (new Date()); + if ( + isSame // if the same post it could have been updated + || (isPublished && isNewer && !isInFuture) + ) { + cachedLastNewsPost = newPost; + } +}; + +export const model = mongoose.model('NewsPost', schema); + +function getAndUpdateLastNewsPost () { + model.getLastPostFromDatabase().then(lastPost => { + if (lastPost) { + model.updateLastNewsPost(lastPost); + } + }).catch(err => logger.error(err)); +} + +export function refreshNewsPost (interval) { + return setInterval(() => getAndUpdateLastNewsPost(), interval); +} + +// Fetches the last news post and refresh it every 5 minutes +getAndUpdateLastNewsPost(); +refreshNewsPost(NEWS_CACHE_TIME); diff --git a/website/server/models/user/hooks.js b/website/server/models/user/hooks.js index 8ee9e8d2e8..3bcbab791b 100644 --- a/website/server/models/user/hooks.js +++ b/website/server/models/user/hooks.js @@ -31,6 +31,10 @@ schema.plugin(baseModel, { delete plainObj.filters; + if (plainObj.flags && originalDoc.isSelected('flags.lastNewStuffRead')) { + plainObj.flags.newStuff = originalDoc.checkNewStuff(); + } + return plainObj; }, }); diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 9c7d0d9a7c..d92fd30557 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -22,6 +22,7 @@ import * as inboxLib from '../../libs/inbox'; // eslint-disable-line import/no-c import amazonPayments from '../../libs/payments/amazon'; // eslint-disable-line import/no-cycle import stripePayments from '../../libs/payments/stripe'; // eslint-disable-line import/no-cycle import paypalPayments from '../../libs/payments/paypal'; // eslint-disable-line import/no-cycle +import { model as NewsPost } from '../newsPost'; const { daysSince } = common; @@ -295,6 +296,12 @@ schema.statics.transformJSONUser = function transformJSONUser (jsonUser, addComp if (addComputedStats) this.addComputedStatsToJSONObj(jsonUser.stats, jsonUser); }; +// Returns true if the user has read the last news post +schema.methods.checkNewStuff = function checkNewStuff () { + const lastNewsPost = NewsPost.lastNewsPost(); + return Boolean(lastNewsPost && this.flags && this.flags.lastNewStuffRead !== lastNewsPost._id); +}; + // Add stats.toNextLevel, stats.maxMP and stats.maxHealth // to a JSONified User stats object schema.statics.addComputedStatsToJSONObj = function addComputedStatsToUserJSONObj ( @@ -491,7 +498,11 @@ schema.methods.isMemberOfGroupPlan = async function isMemberOfGroupPlan () { }; schema.methods.isAdmin = function isAdmin () { - return this.contributor && this.contributor.admin; + return Boolean(this.contributor && this.contributor.admin); +}; + +schema.methods.isNewsPoster = function isNewsPoster () { + return Boolean(this.contributor && this.contributor.newsPoster); }; // When converting to json add inbox messages from the Inbox collection diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js index abc6426a56..dc62630f59 100644 --- a/website/server/models/user/schema.js +++ b/website/server/models/user/schema.js @@ -158,6 +158,7 @@ export default new Schema({ max: 9, }, admin: Boolean, + newsPoster: Boolean, sudo: Boolean, // Artisan, Friend, Blacksmith, etc text: String, @@ -245,7 +246,11 @@ export default new Schema({ }, dropsEnabled: { $type: Boolean, default: false }, // unused itemsEnabled: { $type: Boolean, default: false }, - newStuff: { $type: Boolean, default: false }, + lastNewStuffRead: { $type: String, default: '' }, + // The newStuff field was changed to be a computed property when returning the user in json, + // so that it doesn't have to be updated for each bailey post. + // See models/user/hooks#toJSONTransform + // newStuff: { $type: Boolean, default: false }, rewrite: { $type: Boolean, default: true }, classSelected: { $type: Boolean, default: false }, mathUpdates: Boolean,