From 528f59c6447dfae0f46a8c38140b0318b2230f27 Mon Sep 17 00:00:00 2001 From: Kalista Payne Date: Wed, 5 Aug 2026 17:18:23 -0500 Subject: [PATCH 1/3] perf(v4): exclude unused starter content --- test/api/v4/content/GET-content.test.js | 87 ++++++++++++++++++ website/server/controllers/api-v4/content.js | 93 ++++++++++++++++++++ website/server/libs/content.js | 1 + website/server/middlewares/appRoutes.js | 1 + 4 files changed, 182 insertions(+) create mode 100644 test/api/v4/content/GET-content.test.js create mode 100644 website/server/controllers/api-v4/content.js diff --git a/test/api/v4/content/GET-content.test.js b/test/api/v4/content/GET-content.test.js new file mode 100644 index 00000000000..3e267e5aeb7 --- /dev/null +++ b/test/api/v4/content/GET-content.test.js @@ -0,0 +1,87 @@ +import { + requester, + translate as t, + generateUser, +} from '../../../../helpers/api-integration/v3'; +import i18n from '../../../../../website/common/script/i18n'; + +describe('GET /content', () => { + it('returns content (and does not require authentication)', async () => { + const res = await requester().get('/content'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); + }); + + it('returns content not in English', async () => { + const res = await requester().get('/content?language=de'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'de')); + }); + + it('falls back to English if the desired language is not found', async () => { + const res = await requester().get('/content?language=wrong'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); + }); + + it('filters out starter task content by default', async () => { + const res = await requester().get('/content'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014'); + expect(res).to.have.nested.property('gear.tree'); + expect(res).to.not.have.nested.property('userDefaults'); + expect(res).to.not.have.nested.property('tasksByCategory'); + expect(res).to.not.have.nested.property('userDefaultsMobile'); + }); + + it('filters content automatically for iOS requests', async () => { + const res = await requester(null, { 'x-client': 'habitica-ios' }).get('/content'); + expect(res).to.have.nested.property('appearances.background.beach'); + expect(res).to.not.have.nested.property('backgrounds.backgrounds062014'); + expect(res).to.not.have.property('backgroundsFlat'); + expect(res).to.not.have.nested.property('gear.tree'); + }); + + it('filters content automatically for Android requests', async () => { + const res = await requester(null, { 'x-client': 'habitica-android' }).get('/content'); + expect(res).to.not.have.nested.property('appearances.background.beach'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014'); + expect(res).to.not.have.property('backgroundsFlat'); + expect(res).to.not.have.nested.property('gear.tree'); + }); + + it('filters content if the request specifies a filter', async () => { + const res = await requester().get('/content?filter=backgroundsFlat,gear.flat'); + expect(res).to.not.have.property('backgroundsFlat'); + expect(res).to.have.nested.property('gear.tree'); + expect(res).to.not.have.nested.property('gear.flat'); + }); + + it('filters content if the request contains invalid filters', async () => { + const res = await requester().get('/content?filter=backgroundsFlat,invalid'); + expect(res).to.not.have.property('backgroundsFlat'); + }); + + describe('authenticated user', () => { + let user; + it('returns content in user\'s preferred language when no language parameter is provided', async () => { + user = await generateUser({ 'preferences.language': 'de' }); + const res = await user.get('/content'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'de')); + }); + + it('respects language parameter over user\'s preferred language', async () => { + user = await generateUser({ 'preferences.language': 'de' }); + const res = await user.get('/content?language=fr'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'fr')); + }); + + it('falls back to English if user\'s preferred language is invalid', async () => { + user = await generateUser({ 'preferences.language': 'invalid_lang' }); + const res = await user.get('/content'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); + }); + }); +}); diff --git a/website/server/controllers/api-v4/content.js b/website/server/controllers/api-v4/content.js new file mode 100644 index 00000000000..abbeab3986a --- /dev/null +++ b/website/server/controllers/api-v4/content.js @@ -0,0 +1,93 @@ +import nconf from 'nconf'; +import { langCodes } from '../../libs/i18n'; +import { serveContent, ANDROID_FILTER, IOS_FILTER, V4_FILTER } from '../../libs/content'; +import { authWithHeaders } from '../../middlewares/auth'; + +const IS_PROD = nconf.get('IS_PROD'); + +const api = {}; + +/** + * @api {get} /api/v4/content Get all available content objects + * @apiDescription Does not require authentication. + * @apiName ContentGet + * @apiGroup Content + * + * @apiParam (Query) {String="bg","cs","da","de", + * "en","en@pirate","en_GB", + * "es","es_419","fr","he","hu", + * "id","it","ja","nl","pl","pt","pt_BR", + * "ro","ru","sk","sr","sv", + * "uk","zh","zh_TW"} [language=en] Language code used for the items' + * strings. If the authenticated user makes + * the request, the content will return with + * the user's configured language. + * + * @apiSuccess {Object} data Various data about the content of Habitica. The content route + * contains many keys, but the data listed below are the recommended data to use. + * @apiSuccess {Object} data.mystery The mystery sets awarded to paying subscribers. + * @apiSuccess {Object} data.gear The gear that can be equipped. + * @apiSuccess {Object} data.gear.tree Detailed information about the gear, organized by type. + * @apiSuccess {Object} data.gear.flat The full key of each equipment. + * @apiSuccess {Object} data.spells The skills organized by class. Includes cards and visual buffs. + * @apiSuccess {Object} data.potion Data about the health potion. + * @apiSuccess {Object} data.armoire Data about the armoire. + * @apiSuccess {Array} data.classes The available classes. + * @apiSuccess {Object} data.eggs All available eggs. + * @apiSuccess {Object} data.timeTravelStable The animals available + * in the Time Traveler's stable, separated + * into pets and mounts. + * @apiSuccess {Object} data.hatchingPotions All the hatching potions. + * @apiSuccess {Object} data.petInfo All the pets with extra info. + * @apiSuccess {Object} data.mountInfo All the mounts with extra info. + * @apiSuccess {Object} data.food All the food. + * @apiSuccess {Array} data.userCanOwnQuestCategories The types of quests that a user can own. + * @apiSuccess {Object} data.quests Data about the quests. + * @apiSuccess {Object} data.appearances Data about the appearance properties. + * @apiSuccess {Object} data.appearances.hair Data about available hair options. + * @apiSuccess {Object} data.appearances.shirt Data about available shirt options. + * @apiSuccess {Object} data.appearances.size Data about available body size options. + * @apiSuccess {Object} data.appearances.skin Data about available skin options. + * @apiSuccess {Object} data.appearances.chair Data about available chair options. + * @apiSuccess {Object} data.appearances.background Data about available background options. + * @apiSuccess {Object} data.backgrounds Data about the background sets. + * @apiSuccess {Object} data.subscriptionBlocks Data about the various subscriptions blocks. + * + */ +api.getContent = { + method: 'GET', + url: '/content', + noLanguage: true, + middlewares: [authWithHeaders({ optional: true })], + async handler (req, res) { + let language = 'en'; + const proposedLang = req.query.language; + + if (proposedLang && langCodes.includes(proposedLang)) { + language = proposedLang; + } else if (res.locals.user + && res.locals.user.preferences + && res.locals.user.preferences.language + ) { + const userLang = res.locals.user.preferences.language; + if (langCodes.includes(userLang)) { + language = userLang; + } + } + + let filter = req.query.filter || ''; + // apply defaults for mobile clients + if (filter === '') { + if (req.headers['x-client'] === 'habitica-android') { + filter = ANDROID_FILTER; + } else if (req.headers['x-client'] === 'habitica-ios') { + filter = IOS_FILTER; + } + } + filter = [...filter, V4_FILTER].join(','); + + serveContent(res, language, filter, IS_PROD); + }, +}; + +export default api; diff --git a/website/server/libs/content.js b/website/server/libs/content.js index b4f57508ac9..b1351a8f705 100644 --- a/website/server/libs/content.js +++ b/website/server/libs/content.js @@ -17,6 +17,7 @@ const MOBILE_FILTER = ['achievements', 'questSeriesAchievements', 'animalColorAc export const ANDROID_FILTER = [...MOBILE_FILTER, 'appearances.background'].join(','); export const IOS_FILTER = [...MOBILE_FILTER, 'backgrounds'].join(','); +export const V4_FILTER = 'userDefaults,tasksByCategory,userDefaultsMobile'; function getDay (date) { if (date === undefined) { diff --git a/website/server/middlewares/appRoutes.js b/website/server/middlewares/appRoutes.js index 33b3b325694..24400338297 100644 --- a/website/server/middlewares/appRoutes.js +++ b/website/server/middlewares/appRoutes.js @@ -42,6 +42,7 @@ app.use('/api/v3', v3RateLimiter, v3Router); const v4RouterOverrides = [ // 'GET-/status', Example to override the GET /status api call 'POST-/user/auth/local/register', + 'GET-/content', 'GET-/news', 'GET-/user', 'PUT-/user', From 27e9ef0f8fcbfb375e71be8aa3125b8ecb051473 Mon Sep 17 00:00:00 2001 From: Kalista Payne Date: Wed, 5 Aug 2026 17:31:20 -0500 Subject: [PATCH 2/3] fix(lint): imports --- test/api/v4/content/GET-content.test.js | 4 ++-- website/server/controllers/api-v4/content.js | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/test/api/v4/content/GET-content.test.js b/test/api/v4/content/GET-content.test.js index 3e267e5aeb7..1e13064bfb3 100644 --- a/test/api/v4/content/GET-content.test.js +++ b/test/api/v4/content/GET-content.test.js @@ -2,8 +2,8 @@ import { requester, translate as t, generateUser, -} from '../../../../helpers/api-integration/v3'; -import i18n from '../../../../../website/common/script/i18n'; +} from '../../../helpers/api-integration/v4'; +import i18n from '../../../../website/common/script/i18n'; describe('GET /content', () => { it('returns content (and does not require authentication)', async () => { diff --git a/website/server/controllers/api-v4/content.js b/website/server/controllers/api-v4/content.js index abbeab3986a..621e32d4eda 100644 --- a/website/server/controllers/api-v4/content.js +++ b/website/server/controllers/api-v4/content.js @@ -1,6 +1,11 @@ import nconf from 'nconf'; import { langCodes } from '../../libs/i18n'; -import { serveContent, ANDROID_FILTER, IOS_FILTER, V4_FILTER } from '../../libs/content'; +import { + serveContent, + ANDROID_FILTER, + IOS_FILTER, + V4_FILTER, +} from '../../libs/content'; import { authWithHeaders } from '../../middlewares/auth'; const IS_PROD = nconf.get('IS_PROD'); From 431e4157465aef98d1a43a559535eb50a6bc37f7 Mon Sep 17 00:00:00 2001 From: Kalista Payne Date: Fri, 7 Aug 2026 12:49:52 -0500 Subject: [PATCH 3/3] fix(content): strings and arrays lol --- website/server/controllers/api-v4/content.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/website/server/controllers/api-v4/content.js b/website/server/controllers/api-v4/content.js index 621e32d4eda..ebcf409c672 100644 --- a/website/server/controllers/api-v4/content.js +++ b/website/server/controllers/api-v4/content.js @@ -84,12 +84,13 @@ api.getContent = { // apply defaults for mobile clients if (filter === '') { if (req.headers['x-client'] === 'habitica-android') { - filter = ANDROID_FILTER; + filter = [V4_FILTER, ANDROID_FILTER].join(','); } else if (req.headers['x-client'] === 'habitica-ios') { - filter = IOS_FILTER; + filter = [V4_FILTER, IOS_FILTER].join(','); + } else { + filter = V4_FILTER; } } - filter = [...filter, V4_FILTER].join(','); serveContent(res, language, filter, IS_PROD); },