From a6792af5b102a576b29dc517060cb7cfaf26982a Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Fri, 14 Aug 2026 08:44:47 +0200 Subject: [PATCH] Merge pull request #36225 from overleaf/jpa-zod-web-final [web] fully adopt strict zod schema and enforce in tests GitOrigin-RevId: 1bb03ed3a819be6de38c8ca5758b3909cfa23318 --- .../Collaborators/CollaboratorsController.mjs | 143 ++++++++++++++++-- .../CollaboratorsInviteController.mjs | 140 +++++++++++++++-- 2 files changed, 258 insertions(+), 25 deletions(-) diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs index 75c512d26b..f06e079ea6 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs @@ -40,9 +40,19 @@ export default { grantAccessRequest: expressify(grantAccessRequest), } +const removeUserFromProjectSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + user_id: zz.objectId(), + }), +}) + async function removeUserFromProject(req, res, next) { - const projectId = req.params.Project_id - const userId = req.params.user_id + const { params } = parseReq(req, removeUserFromProjectSchema, { + logOnly: true, + }) + const projectId = params.Project_id + const userId = params.user_id const sessionUserId = SessionManager.getLoggedInUserId(req.session) await _removeUserIdFromProject(projectId, userId) EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', { @@ -68,8 +78,17 @@ async function removeUserFromProject(req, res, next) { res.sendStatus(204) } +const removeSelfFromProjectSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), +}) + async function removeSelfFromProject(req, res, next) { - const projectId = req.params.Project_id + const { params } = parseReq(req, removeSelfFromProjectSchema, { + logOnly: true, + }) + const projectId = params.Project_id const userId = SessionManager.getLoggedInUserId(req.session) await _removeUserIdFromProject(projectId, userId) EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', { @@ -86,8 +105,15 @@ async function removeSelfFromProject(req, res, next) { res.sendStatus(204) } +const getAllMembersSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), +}) + async function getAllMembers(req, res, next) { - const projectId = req.params.Project_id + const { params } = parseReq(req, getAllMembersSchema, { logOnly: true }) + const projectId = params.Project_id logger.debug({ projectId }, 'getting all active members for project') let members try { @@ -98,11 +124,20 @@ async function getAllMembers(req, res, next) { res.json({ members }) } +const getAccessRequestsSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), +}) + // The pending access requests carry requester identities, so this is admin // gated by the router (ensureUserCanAdminProject). A requester's own // `myAccessRequest` is delivered via the editor bootstrap instead. async function getAccessRequests(req, res) { - const projectId = req.params.Project_id + const { params } = parseReq(req, getAccessRequestsSchema, { + logOnly: true, + }) + const projectId = params.Project_id const projectAccess = await CollaboratorsGetter.promises.getProjectAccess(projectId) const editAccessRequests = await projectAccess.loadAccessRequestsView() @@ -110,6 +145,22 @@ async function getAccessRequests(req, res) { } const setCollaboratorInfoSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + user_id: zz.objectId(), + }), + body: z.strictObject({ + privilegeLevel: z.enum([ + PrivilegeLevels.READ_ONLY, + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW, + ]), + }), +}) + +// Rollout-temporary fallback (pre-refinement schema from main); delete +// when this route's REQ_VALIDATION_MODE instrumentation is removed. +const setCollaboratorInfoFallbackSchema = z.object({ params: z.object({ Project_id: zz.objectId(), user_id: zz.objectId(), @@ -125,7 +176,9 @@ const setCollaboratorInfoSchema = z.object({ async function setCollaboratorInfo(req, res, next) { try { - const { params, body } = parseReq(req, setCollaboratorInfoSchema) + const { params, body } = parseReq(req, setCollaboratorInfoSchema, { + fallbackSchema: setCollaboratorInfoFallbackSchema, + }) const projectId = params.Project_id const userId = params.user_id const { privilegeLevel } = body @@ -172,6 +225,17 @@ async function setCollaboratorInfo(req, res, next) { } const transferOwnershipSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), + body: z.strictObject({ + user_id: zz.objectId(), + }), +}) + +// Rollout-temporary fallback (pre-refinement schema from main); delete +// when this route's REQ_VALIDATION_MODE instrumentation is removed. +const transferOwnershipFallbackSchema = z.object({ params: z.object({ Project_id: zz.objectId(), }), @@ -182,7 +246,9 @@ const transferOwnershipSchema = z.object({ async function transferOwnership(req, res, next) { const sessionUser = SessionManager.getSessionUser(req.session) - const { params, body } = parseReq(req, transferOwnershipSchema) + const { params, body } = parseReq(req, transferOwnershipSchema, { + fallbackSchema: transferOwnershipFallbackSchema, + }) const projectId = params.Project_id const toUserId = body.user_id try { @@ -214,6 +280,20 @@ async function transferOwnership(req, res, next) { } const requestAccessSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), + body: z.strictObject({ + privilegeLevel: z.enum([ + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW, + ]), + }), +}) + +// Rollout-temporary fallback (pre-refinement schema from main); delete +// when this route's REQ_VALIDATION_MODE instrumentation is removed. +const requestAccessFallbackSchema = z.object({ params: z.object({ Project_id: zz.objectId(), }), @@ -238,7 +318,9 @@ const REQUESTABLE_LEVELS_BY_CURRENT_LEVEL = { } async function requestAccess(req, res) { - const { params, body } = parseReq(req, requestAccessSchema) + const { params, body } = parseReq(req, requestAccessSchema, { + fallbackSchema: requestAccessFallbackSchema, + }) const projectId = params.Project_id const { privilegeLevel } = body const userId = SessionManager.getLoggedInUserId(req.session) @@ -279,6 +361,18 @@ async function requestAccess(req, res) { } const declineAccessRequestSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + user_id: zz.objectId(), + }), + body: z.strictObject({ + notify: z.boolean().optional(), + }), +}) + +// Rollout-temporary fallback (pre-refinement schema from main); delete +// when this route's REQ_VALIDATION_MODE instrumentation is removed. +const declineAccessRequestFallbackSchema = z.object({ params: z.object({ Project_id: zz.objectId(), user_id: zz.objectId(), @@ -289,7 +383,9 @@ const declineAccessRequestSchema = z.object({ }) async function declineAccessRequest(req, res) { - const { params, body } = parseReq(req, declineAccessRequestSchema) + const { params, body } = parseReq(req, declineAccessRequestSchema, { + fallbackSchema: declineAccessRequestFallbackSchema, + }) const projectId = params.Project_id const userId = params.user_id const notify = body.notify === true @@ -323,6 +419,22 @@ async function declineAccessRequest(req, res) { } const grantAccessRequestSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + user_id: zz.objectId(), + }), + body: z.strictObject({ + privilegeLevel: z.enum([ + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW, + ]), + notify: z.boolean().optional(), + }), +}) + +// Rollout-temporary fallback (pre-refinement schema from main); delete +// when this route's REQ_VALIDATION_MODE instrumentation is removed. +const grantAccessRequestFallbackSchema = z.object({ params: z.object({ Project_id: zz.objectId(), user_id: zz.objectId(), @@ -337,7 +449,9 @@ const grantAccessRequestSchema = z.object({ }) async function grantAccessRequest(req, res) { - const { params, body } = parseReq(req, grantAccessRequestSchema) + const { params, body } = parseReq(req, grantAccessRequestSchema, { + fallbackSchema: grantAccessRequestFallbackSchema, + }) const projectId = params.Project_id const requesterId = params.user_id const { privilegeLevel, notify } = body @@ -507,8 +621,15 @@ async function _removeUserIdFromProject(projectId, userId) { await TagsHandler.promises.removeProjectFromAllTags(userId, projectId) } +const getShareTokensSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), +}) + async function getShareTokens(req, res) { - const projectId = req.params.Project_id + const { params } = parseReq(req, getShareTokensSchema, { logOnly: true }) + const projectId = params.Project_id const userId = SessionManager.getLoggedInUserId(req.session) if (!Features.hasFeature('link-sharing')) { diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs index 2c73d8220b..7543b42fb1 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs @@ -40,8 +40,15 @@ const rateLimiter = new RateLimiter('invite-to-project-by-user-id', { duration: 60 * 30, }) +const getAllInvitesSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), +}) + async function getAllInvites(req, res) { - const projectId = req.params.Project_id + const { params } = parseReq(req, getAllInvitesSchema, { logOnly: true }) + const projectId = params.Project_id logger.debug({ projectId }, 'getting all active invites for project') const invites = await CollaboratorsInviteGetter.promises.getAllInvites(projectId) @@ -89,6 +96,21 @@ async function _checkRateLimit(userId) { } const inviteToProjectSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), + body: z.strictObject({ + email: z.string(), + privileges: z.enum([ + PrivilegeLevels.READ_ONLY, + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW, + ]), + }), +}) +// Rollout-temporary fallback (pre-refinement schema from main); delete +// when this route's REQ_VALIDATION_MODE instrumentation is removed. +const inviteToProjectFallbackSchema = z.object({ params: z.object({ Project_id: zz.objectId(), }), @@ -103,7 +125,9 @@ const inviteToProjectSchema = z.object({ }) async function inviteToProject(req, res) { - const { params, body } = parseReq(req, inviteToProjectSchema) + const { params, body } = parseReq(req, inviteToProjectSchema, { + fallbackSchema: inviteToProjectFallbackSchema, + }) const projectId = params.Project_id let { email, privileges } = body const sendingUser = SessionManager.getSessionUser(req.session) @@ -189,9 +213,17 @@ async function inviteToProject(req, res) { }) res.json({ invite }) } +const revokeInviteSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + invite_id: zz.objectId(), + }), +}) + async function revokeInvite(req, res) { - const projectId = req.params.Project_id - const inviteId = req.params.invite_id + const { params } = parseReq(req, revokeInviteSchema, { logOnly: true }) + const projectId = params.Project_id + const inviteId = params.invite_id const user = SessionManager.getSessionUser(req.session) logger.debug({ projectId, inviteId }, 'revoking invite') @@ -223,9 +255,19 @@ async function revokeInvite(req, res) { res.sendStatus(204) } +const generateNewInviteSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + invite_id: zz.objectId(), + }), +}) + async function generateNewInvite(req, res) { - const projectId = req.params.Project_id - const inviteId = req.params.invite_id + const { params } = parseReq(req, generateNewInviteSchema, { + logOnly: true, + }) + const projectId = params.Project_id + const inviteId = params.invite_id const user = SessionManager.getSessionUser(req.session) logger.debug({ projectId, inviteId }, 'resending invite') @@ -275,9 +317,17 @@ const _renderInvalidPage = function (res, projectId, sharingUpdates) { } } +const viewInviteSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + token: z.string(), + }), +}) + async function viewInvite(req, res) { - const projectId = req.params.Project_id - const { token } = req.params + const { params } = parseReq(req, viewInviteSchema, { logOnly: true }) + const projectId = params.Project_id + const { token } = params const { variant: sharingUpdates } = await SplitTestHandler.promises.getAssignment(req, res, 'sharing-updates') @@ -361,8 +411,15 @@ async function viewInvite(req, res) { } } +const viewSharingLinkSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), +}) + async function viewSharingLink(req, res) { - const projectId = req.params.Project_id + const { params } = parseReq(req, viewSharingLinkSchema, { logOnly: true }) + const projectId = params.Project_id // ensure the project exists const project = await ProjectGetter.promises.getProject(projectId, { @@ -397,9 +454,22 @@ async function viewSharingLink(req, res) { }) } +const acceptInviteSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + token: z.string().optional(), + }), + body: z.strictObject({ + token: z.string().optional(), + }), +}) + async function acceptInvite(req, res) { - const { Project_id: projectId, token: urlToken } = req.params - const { token: bodyToken } = req.body + const { params, body } = parseReq(req, acceptInviteSchema, { + logOnly: true, + }) + const { Project_id: projectId, token: urlToken } = params + const { token: bodyToken } = body const currentUser = SessionManager.getSessionUser(req.session) logger.debug( { projectId, userId: currentUser._id }, @@ -519,8 +589,16 @@ async function acceptInvite(req, res) { } } +const getSharingLinkSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), +}) + async function getSharingLink(req, res) { - const { Project_id: projectId } = req.params + const { Project_id: projectId } = parseReq(req, getSharingLinkSchema, { + logOnly: true, + }).params const invite = await CollaboratorsInviteGetter.promises.getSharingLinkInvite(projectId) @@ -541,6 +619,26 @@ async function getSharingLink(req, res) { } const updateSharingLinkSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), + body: z.strictObject({ + // We have to use a union here, as Zod enums must be strings, + // but NONE is defined as boolean false + privileges: z.union([ + z.literal(PrivilegeLevels.NONE), + z.enum([ + PrivilegeLevels.READ_ONLY, + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW, + ]), + ]), + subscriptionId: zz.objectId().optional(), + }), +}) +// Rollout-temporary fallback (pre-refinement schema from main); delete +// when this route's REQ_VALIDATION_MODE instrumentation is removed. +const updateSharingLinkFallbackSchema = z.object({ params: z.object({ Project_id: zz.objectId(), }), @@ -560,7 +658,9 @@ const updateSharingLinkSchema = z.object({ }) async function updateSharingLink(req, res) { - const { params, body } = parseReq(req, updateSharingLinkSchema) + const { params, body } = parseReq(req, updateSharingLinkSchema, { + fallbackSchema: updateSharingLinkFallbackSchema, + }) const projectId = params.Project_id const privileges = body.privileges const subscriptionId = body.subscriptionId @@ -635,6 +735,16 @@ async function updateSharingLink(req, res) { } const validateSharingLinkSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), + body: z.strictObject({ + token: z.string(), + }), +}) +// Rollout-temporary fallback (pre-refinement schema from main); delete +// when this route's REQ_VALIDATION_MODE instrumentation is removed. +const validateSharingLinkFallbackSchema = z.object({ params: z.object({ Project_id: zz.objectId(), }), @@ -644,7 +754,9 @@ const validateSharingLinkSchema = z.object({ }) async function validateSharingLink(req, res) { - const { params, body } = parseReq(req, validateSharingLinkSchema) + const { params, body } = parseReq(req, validateSharingLinkSchema, { + fallbackSchema: validateSharingLinkFallbackSchema, + }) const projectId = params.Project_id const { token } = body