diff --git a/.circleci/config.yml b/.circleci/config.yml index 51f22b0..38b3184 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -69,6 +69,7 @@ workflows: - develop - pm-2539 - PS-511 + - PS-513-Hotfix # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/ReadMe.md b/ReadMe.md index fbcaa34..b3201f3 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -79,6 +79,7 @@ The following parameters can be set in config files or in env variables: - PORT: the server port, default is 3000 - AUTH_SECRET: The authorization secret used during token verification. - VALID_ISSUERS: The valid issuer of tokens. +- VANILLA_DB_URL: MySQL connection string for the Vanilla forums database. - AUTH0_URL: AUTH0 URL, used to get M2M token - AUTH0_PROXY_SERVER_URL: AUTH0 proxy server URL, used to get M2M token - AUTH0_AUDIENCE: AUTH0 audience, used to get M2M token diff --git a/config/default.js b/config/default.js index 5da07cd..433982f 100644 --- a/config/default.js +++ b/config/default.js @@ -9,6 +9,7 @@ module.exports = { AUTH_SECRET: process.env.AUTH_SECRET || 'mysecret', VALID_ISSUERS: process.env.VALID_ISSUERS || '["https://api.topcoder-dev.com", "https://api.topcoder.com", "https://topcoder-dev.auth0.com/", "https://auth.topcoder-dev.com/"]', IDENTITY_DB_URL: process.env.IDENTITY_DB_URL, + VANILLA_DB_URL: process.env.VANILLA_DB_URL, // used to get M2M token AUTH0_URL: process.env.AUTH0_URL, diff --git a/config/test.js b/config/test.js index f7d627c..04b5778 100644 --- a/config/test.js +++ b/config/test.js @@ -10,5 +10,6 @@ module.exports = { M2M_FULL_ACCESS_TOKEN: process.env.M2M_FULL_ACCESS_TOKEN || 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoiZW5qdzE4MTBlRHozWFR3U08yUm4yWTljUVRyc3BuM0JAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vbTJtLnRvcGNvZGVyLWRldi5jb20vIiwiaWF0IjoxNTUwOTA2Mzg4LCJleHAiOjE2ODA5OTI3ODgsImF6cCI6ImVuancxODEwZUR6M1hUd1NPMlJuMlk5Y1FUcnNwbjNCIiwic2NvcGUiOiJhbGw6bWVtYmVycyIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.Eo_cyyPBQfpWp_8-NSFuJI5MvkEV3UJZ3ONLcFZedoA', M2M_READ_ACCESS_TOKEN: process.env.M2M_READ_ACCESS_TOKEN || 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoiZW5qdzE4MTBlRHozWFR3U08yUm4yWTljUVRyc3BuM0JAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vbTJtLnRvcGNvZGVyLWRldi5jb20vIiwiaWF0IjoxNTUwOTA2Mzg4LCJleHAiOjE2ODA5OTI3ODgsImF6cCI6ImVuancxODEwZUR6M1hUd1NPMlJuMlk5Y1FUcnNwbjNCIiwic2NvcGUiOiJyZWFkOm1lbWJlcnMiLCJndHkiOiJjbGllbnQtY3JlZGVudGlhbHMifQ.F-dEZXJC7Ue7dHCi3XQdEvxhtr69hU4MwTcr-APHnK4', M2M_UPDATE_ACCESS_TOKEN: process.env.M2M_UPDATE_ACCESS_TOKEN || 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoiZW5qdzE4MTBlRHozWFR3U08yUm4yWTljUVRyc3BuM0JAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vbTJtLnRvcGNvZGVyLWRldi5jb20vIiwiaWF0IjoxNTUwOTA2Mzg4LCJleHAiOjE2ODA5OTI3ODgsImF6cCI6ImVuancxODEwZUR6M1hUd1NPMlJuMlk5Y1FUcnNwbjNCIiwic2NvcGUiOiJ1cGRhdGU6bWVtYmVycyIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.wImcvhkF9QPOCSEfZ01U-YxYM8NZi1yqgRmw3eiNn1Q', - S3_ENDPOINT: process.env.S3_ENDPOINT || 'localhost:9000' + S3_ENDPOINT: process.env.S3_ENDPOINT || 'localhost:9000', + VANILLA_DB_URL: process.env.VANILLA_DB_URL } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e24fc87..b3d06e8 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -286,6 +286,8 @@ paths: description: |- Update the member profile by handle. + Handle updates are not supported on this endpoint. Use `PATCH /members/{handle}/change_handle` to change handles. + If the email has been changed, the email change process starts and a verification email is sent to the new and old email address. Authorization: @@ -332,6 +334,59 @@ paths: description: Internal server error schema: $ref: '#/definitions/ErrorModel' + '/members/{handle}/change_handle': + patch: + tags: + - Basic + description: |- + Update the member handle. + + Authorization: + - JWT roles: Only the profile owner or users with `administrator`/`admin` roles may update member data. + - M2M scopes: `update:user_profiles` or `all:user_profiles`. + security: + - bearer: [] + parameters: + - in: path + name: handle + required: true + type: string + - in: query + name: fields + required: false + type: string + description: > + Comma separated list of fields to include in the response. Defaults to all member fields. + - in: body + name: body + required: true + schema: + $ref: '#/definitions/MemberHandleUpdate' + responses: + '200': + description: OK + schema: + $ref: '#/definitions/MemberProfile' + '400': + description: Bad request data + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Miss or wrong authentication credentials + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: No permission + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal server error + schema: + $ref: '#/definitions/ErrorModel' '/members/{handle}/profileCompleteness': get: tags: @@ -1793,6 +1848,14 @@ definitions: description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' updatedBy: type: string + MemberHandleUpdate: + type: object + required: + - newHandle + properties: + newHandle: + type: string + description: New handle for the member. EmailVerificationResult: type: object properties: diff --git a/package.json b/package.json index cb32ca0..f66c922 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "lodash": "^4.17.19", "mime-types": "^2.1.35", "moment": "^2.27.0", + "mysql2": "^3.14.3", "prisma": "^6.10.1", "request": "^2.88.2", "sharp": "^0.34.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c264ab5..c12b7a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: moment: specifier: ^2.27.0 version: 2.30.1 + mysql2: + specifier: ^3.14.3 + version: 3.16.2 prisma: specifier: ^6.10.1 version: 6.13.0 @@ -3162,6 +3165,47 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru.min@1.1.3: + resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + mysql2@3.16.2: + resolution: {integrity: sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + snapshots: '@ampproject/remapping@2.3.0': @@ -6652,3 +6696,41 @@ snapshots: yargs-parser: 21.1.1 yocto-queue@0.1.0: {} + + aws-ssl-profiles@1.1.2: {} + + denque@2.1.0: {} + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + is-property@1.0.2: {} + + long@5.3.2: {} + + lru.min@1.1.3: {} + + mysql2@3.16.2: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.3 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.3 + + seq-queue@0.0.5: {} + + sqlstring@2.3.3: {} diff --git a/src/common/vanillaDb.js b/src/common/vanillaDb.js new file mode 100644 index 0000000..868d09d --- /dev/null +++ b/src/common/vanillaDb.js @@ -0,0 +1,21 @@ +const config = require('config') +const mysql = require('mysql2/promise') +const errors = require('./errors') + +let vanillaPool + +function getVanillaPool () { + if (!config.VANILLA_DB_URL) { + throw new errors.BadRequestError('VANILLA_DB_URL is not configured') + } + + if (!vanillaPool) { + vanillaPool = mysql.createPool(config.VANILLA_DB_URL) + } + + return vanillaPool +} + +module.exports = { + getVanillaPool +} diff --git a/src/controllers/MemberController.js b/src/controllers/MemberController.js index 65621e8..7315667 100644 --- a/src/controllers/MemberController.js +++ b/src/controllers/MemberController.js @@ -42,6 +42,16 @@ async function updateMember (req, res) { res.send(result) } +/** + * Update member handle + * @param {Object} req the request + * @param {Object} res the response + */ +async function updateHandle (req, res) { + const result = await service.updateHandle(req.authUser, req.params.handle, req.query, req.body) + res.send(result) +} + /** * Verify email * @param {Object} req the request @@ -77,6 +87,7 @@ module.exports = { getProfileCompleteness, getMemberUserIdSignature, updateMember, + updateHandle, verifyEmail, uploadPhoto, deleteMember diff --git a/src/routes.js b/src/routes.js index a809bdd..b098385 100644 --- a/src/routes.js +++ b/src/routes.js @@ -81,6 +81,15 @@ module.exports = { access: constants.ADMIN_ROLES } }, + '/members/:handle/change_handle': { + patch: { + controller: 'MemberController', + method: 'updateHandle', + auth: 'jwt', + access: constants.ADMIN_ROLES, + scopes: [MEMBERS.UPDATE, MEMBERS.ALL] + } + }, '/members/:handle/profileCompleteness': { get: { controller: 'MemberController', diff --git a/src/services/MemberService.js b/src/services/MemberService.js index 684565b..0932433 100644 --- a/src/services/MemberService.js +++ b/src/services/MemberService.js @@ -22,6 +22,7 @@ const { bufferContainsScript } = require('../common/image') const prismaHelper = require('../common/prismaHelper') const prismaManager = require('../common/prisma') const identityPrismaManager = require('../common/identityPrisma') +const vanillaDb = require('../common/vanillaDb') const prisma = prismaManager.getClient() const skillsPrisma = prismaManager.getSkillsClient() @@ -314,6 +315,9 @@ async function updateMember (currentUser, handle, query, data) { if (!helper.canManageMember(currentUser, member)) { throw new errors.ForbiddenError('You are not allowed to update the member.') } + if (_.has(data, 'handle') || _.has(data, 'handleLower')) { + throw new errors.BadRequestError('Handle updates must use the handle update endpoint.') + } // validate and parse query parameter const selectFields = helper.parseCommaSeparatedString(query.fields, MEMBER_FIELDS) || MEMBER_FIELDS // check if email has changed @@ -406,6 +410,8 @@ updateMember.schema = { fields: Joi.string() }), data: Joi.object().keys({ + handle: Joi.forbidden(), + handleLower: Joi.forbidden(), firstName: Joi.string(), lastName: Joi.string(), description: Joi.string().allow(''), @@ -431,6 +437,124 @@ updateMember.schema = { }).required() } +/** + * Update member handle. + * @param {Object} currentUser the user who performs operation + * @param {String} handle the member handle + * @param {Object} query the query parameters + * @param {Object} data the handle update payload + * @returns {Object} the updated member data + */ +async function updateHandle (currentUser, handle, query, data) { + const operatorId = currentUser.userId || currentUser.sub + const member = await helper.getMemberByHandle(handle) + + if (!helper.canManageMember(currentUser, member)) { + throw new errors.ForbiddenError('You are not allowed to update the member handle.') + } + + const newHandle = (data.newHandle || '').trim() + if (!newHandle) { + throw new errors.BadRequestError('newHandle is required') + } + + const selectFields = helper.parseCommaSeparatedString(query.fields, MEMBER_FIELDS) || MEMBER_FIELDS + + if (newHandle === member.handle) { + return getMember(currentUser, handle, query) + } + + const newHandleLower = newHandle.toLowerCase() + const existingMember = await prisma.member.findUnique({ + where: { handleLower: newHandleLower } + }) + if (existingMember && String(existingMember.userId) !== String(member.userId)) { + throw new errors.BadRequestError(`Handle "${newHandle}" is already registered`) + } + + const identityPrisma = identityPrismaManager.getIdentityClient() + const identityUserId = helper.bigIntToNumber(member.userId) + const existingIdentity = await identityPrisma.user.findFirst({ + where: { handle_lower: newHandleLower } + }) + if (existingIdentity && Number(existingIdentity.user_id) !== identityUserId) { + throw new errors.BadRequestError(`Handle "${newHandle}" is already registered`) + } + + const vanillaPool = vanillaDb.getVanillaPool() + const now = new Date() + let updatedMember + let identityUpdated = false + let memberUpdated = false + let vanillaUpdated = false + + try { + await updateIdentityHandle(identityUserId, member.handle, newHandle, now) + identityUpdated = true + + updatedMember = await prisma.member.update({ + where: { userId: member.userId }, + data: { + handle: newHandle, + handleLower: newHandleLower, + updatedAt: now, + updatedBy: operatorId + }, + include: { addresses: true } + }) + memberUpdated = true + + await updateVanillaHandle(member.handle, newHandle, vanillaPool) + vanillaUpdated = true + } catch (err) { + if (vanillaUpdated) { + try { + await updateVanillaHandle(newHandle, member.handle, vanillaPool) + } catch (rollbackErr) { + logger.error(`Failed to rollback Vanilla handle update for ${member.userId}: ${rollbackErr.message}`) + } + } + if (memberUpdated) { + try { + await prisma.member.update({ + where: { userId: member.userId }, + data: { + handle: member.handle, + handleLower: member.handleLower, + updatedAt: new Date(), + updatedBy: operatorId + } + }) + } catch (rollbackErr) { + logger.error(`Failed to rollback member handle update for ${member.userId}: ${rollbackErr.message}`) + } + } + if (identityUpdated) { + try { + await updateIdentityHandle(identityUserId, newHandle, member.handle, new Date()) + } catch (rollbackErr) { + logger.error(`Failed to rollback identity handle update for ${member.userId}: ${rollbackErr.message}`) + } + } + throw err + } + + prismaHelper.convertMember(updatedMember) + await helper.postBusEvent(constants.TOPICS.MemberUpdated, updatedMember) + return cleanMember(currentUser, updatedMember, selectFields) +} + +updateHandle.schema = { + currentUser: Joi.any(), + handle: Joi.string().required(), + query: Joi.object().keys({ + fields: Joi.string() + }), + data: Joi.object().keys({ + newHandle: Joi.string().required() + }).required() +} + /** * Verify email. * @param {Object} currentUser the user who performs operation @@ -698,6 +822,46 @@ function generateNanoId (size = 21) { return id } +async function updateVanillaHandle (oldHandle, newHandle, pool) { + const vanillaPool = pool || vanillaDb.getVanillaPool() + const [result] = await vanillaPool.execute( + 'UPDATE vanilla.GDN_User SET Name = ? WHERE Name = ?', + [newHandle, oldHandle] + ) + if (!result || result.affectedRows === 0) { + throw new errors.NotFoundError(`Vanilla user with handle: "${oldHandle}" doesn't exist`) + } +} + +async function updateIdentityHandle (userId, oldHandle, newHandle, timestamp) { + const identityPrisma = identityPrismaManager.getIdentityClient() + const lowerHandle = newHandle.toLowerCase() + const updatedAt = timestamp || new Date() + + let userResult + let securityUserResult + try { + userResult = await identityPrisma.$executeRaw` + UPDATE identity."user" + SET handle=${newHandle}, handle_lower=${lowerHandle}, modify_date=${updatedAt} + WHERE user_id=${userId} + ` + + securityUserResult = await identityPrisma.$executeRaw` + UPDATE identity.security_user + SET user_id=${newHandle} + WHERE user_id=${oldHandle} + ` + } catch (err) { + logger.error(`Failed to update identity handle for user ${userId}: ${err.message}`) + throw err + } + + if (userResult === 0 || securityUserResult === 0) { + throw new Error(`Identity records not updated for user ${userId}`) + } +} + async function updateIdentityRecords (userId, handle, email, timestamp) { const identityPrisma = identityPrismaManager.getIdentityClient() const lowerHandle = handle.toLowerCase() @@ -748,6 +912,7 @@ module.exports = { getProfileCompleteness, getMemberUserIdSignature, updateMember, + updateHandle, verifyEmail, uploadPhoto, deleteMember diff --git a/test/unit/MemberService.test.js b/test/unit/MemberService.test.js index c9531f8..b9c385d 100644 --- a/test/unit/MemberService.test.js +++ b/test/unit/MemberService.test.js @@ -252,6 +252,18 @@ describe('member service unit tests', () => { throw new Error('should not reach here') }) + it('update member - handle change not allowed', async () => { + try { + await service.updateMember({ isMachine: true, sub: 'sub1' }, member2.handle, {}, { + handle: 'newHandle' + }) + } catch (e) { + should.equal(e.message.indexOf('"handle" is not allowed') >= 0, true) + return + } + throw new Error('should not reach here') + }) + it('update member - unexpected field', async () => { try { await service.updateMember({ isMachine: true, sub: 'sub1' }, member2.handle, {}, {