[web] determine rootResourcePath if not set in compile request (#34398)

* [web] determine rootResourcePath if not set in compile request

This affects the following compile requests:
- compile request from "Download PDF" button on project dashboard
  Pre-resolving the rootResourcePath for all projects is too expensive.
  Adding an endpoint to resolve it is too slow and doubles project auth
  check.
- custom frontends
  Can't do much about these. I tried blocking them, requests still come
  in after weeks of returning 400s.

The rootDoc maintenance methods now return {rootResourcePath,rootDocId}
rather than just the rootDocId. This avoids an extra project lookup when
the rootDocId was updated.

GitOrigin-RevId: ab7c539b7ffd162bde0903fe1040a48834a245f5
This commit is contained in:
Jakob Ackermann
2026-06-09 08:07:36 +00:00
committed by Copybot
parent 5eb1f41ef7
commit 7f5af99ee9
10 changed files with 141 additions and 162 deletions
@@ -217,24 +217,6 @@ const _CompileController = {
options.pdfCachingMinChunkSize = pdfCachingMinChunkSize
}
if (!options.rootResourcePath) {
const agent = (req.headers['user-agent'] || '').toLowerCase()
const isKnownOtherFrontend = agent.includes('node-fetch')
logger.warn(
{ isKnownOtherFrontend, req, projectId, userId, options },
'rootResourcePath is missing in request body'
)
if (isKnownOtherFrontend) {
// Reject malformed compile request from "known" other frontend.
res.status(400).json({
error: 'rootResourcePath is missing in request body',
})
return
}
// All others: Log for now and fall back to old compile mode.
options.compileFromHistory = false
}
const {
status,
outputFiles,
@@ -48,7 +48,20 @@ async function compile(projectId, userId, options = {}) {
return { status: 'autocompile-backoff', outputFiles: [] }
}
await ProjectRootDocManager.promises.ensureRootDocumentIsSet(projectId)
if (!options.rootResourcePath) {
const result =
await ProjectRootDocManager.promises.ensureRootDocumentIsValid(projectId)
if (result) {
options.rootDoc_id = result.rootDocId
options.rootResourcePath = result.rootResourcePath.replace(/^\//, '')
} else {
return {
status: 'validation-problems',
validationProblems: { mainFile: 'no main file specified' },
outputFiles: [],
}
}
}
const limits =
await CompileManager.promises.getProjectCompileLimits(projectId)
@@ -1,7 +1,6 @@
import OError from '@overleaf/o-error'
import ProjectGetter from '../Project/ProjectGetter.mjs'
import ProjectHistoryHandler from '../Project/ProjectHistoryHandler.mjs'
import ProjectLocator from '../Project/ProjectLocator.mjs'
import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
import UserGetter from '../User/UserGetter.mjs'
import logger from '@overleaf/logger'
@@ -37,18 +36,26 @@ export default ExportsHandler = {
show_source: showSource,
} = exportParams
let project, rootDoc, user, historyVersion
let project, rootResourcePath, user, historyVersion
try {
project = await ProjectGetter.promises.getProject(projectId)
await ProjectRootDocManager.promises.ensureRootDocumentIsValid(projectId)
rootDoc = await ProjectLocator.promises.findRootDoc({
project,
project_id: projectId,
project = await ProjectGetter.promises.getProject(projectId, {
overleaf: 1,
compiler: 1,
imageName: 1,
})
const result =
await ProjectRootDocManager.promises.ensureRootDocumentIsValid(
projectId
)
if (!result) {
throw new OError('cannot export project without root doc', {
project_id: projectId,
})
}
rootResourcePath = result.rootResourcePath
user = await UserGetter.promises.getUser(userId, {
first_name: 1,
last_name: 1,
@@ -67,12 +74,6 @@ export default ExportsHandler = {
})
}
if (!rootDoc || !rootDoc.path) {
throw new OError('cannot export project without root doc', {
project_id: projectId,
})
}
if (exportParams.first_name && exportParams.last_name) {
user.first_name = exportParams.first_name
user.last_name = exportParams.last_name
@@ -81,7 +82,7 @@ export default ExportsHandler = {
return {
project: {
id: projectId,
rootDocPath: rootDoc.path ? rootDoc.path.fileSystem : undefined,
rootDocPath: rootResourcePath,
historyId: project.overleaf?.history?.id,
historyVersion,
v1ProjectId: project.overleaf != null ? project.overleaf.id : undefined,
@@ -201,27 +201,27 @@ async function updateDocLines(
return { rev, modified }
}
async function setRootDoc(projectId, newRootDocID) {
logger.debug({ projectId, rootDocId: newRootDocID }, 'setting root doc')
if (projectId == null || newRootDocID == null) {
async function setRootDoc(projectId, rootDocId) {
logger.debug({ projectId, rootDocId }, 'setting root doc')
if (projectId == null || rootDocId == null) {
throw new Errors.InvalidError('missing arguments (project or doc)')
}
const docPath =
const rootResourcePath =
await ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
projectId,
newRootDocID
rootDocId
)
if (ProjectEntityUpdateHandler.isPathValidForRootDoc(docPath)) {
if (ProjectEntityUpdateHandler.isPathValidForRootDoc(rootResourcePath)) {
await Project.updateOne(
{ _id: projectId },
{ rootDoc_id: newRootDocID }
{ rootDoc_id: rootDocId }
).exec()
} else {
throw new Errors.UnsupportedFileTypeError(
'invalid file extension for root doc'
)
}
return newRootDocID
return { rootDocId, rootResourcePath }
}
async function unsetRootDoc(projectId) {
@@ -129,19 +129,6 @@ async function setRootDocFromName(projectId, rootDocName) {
}
}
async function ensureRootDocumentIsSet(projectId) {
const project = await ProjectGetter.promises.getProject(projectId, {
rootDoc_id: 1,
})
if (!project) {
throw new Error('project not found')
}
if (project.rootDoc_id != null) {
return
}
return await ProjectRootDocManager.promises.setRootDocAutomatically(projectId)
}
/**
* @param {ObjectId | string} projectId
*/
@@ -154,13 +141,13 @@ async function ensureRootDocumentIsValid(projectId) {
throw new Error('project not found')
}
if (project.rootDoc_id != null) {
const docPath =
const rootResourcePath =
await ProjectEntityHandler.promises.getDocPathFromProjectByDocId(
project,
project.rootDoc_id
)
if (docPath) {
return
if (rootResourcePath) {
return { rootDocId: project.rootDoc_id, rootResourcePath }
}
await ProjectEntityUpdateHandler.promises.unsetRootDoc(projectId)
}
@@ -214,13 +201,11 @@ const ProjectRootDocManager = {
['path', 'content']
),
setRootDocFromName: callbackify(setRootDocFromName),
ensureRootDocumentIsSet: callbackify(ensureRootDocumentIsSet),
ensureRootDocumentIsValid: callbackify(ensureRootDocumentIsValid),
promises: {
setRootDocAutomatically,
findRootDocFileFromDirectory,
setRootDocFromName,
ensureRootDocumentIsSet,
ensureRootDocumentIsValid,
},
}
@@ -116,11 +116,11 @@ const TemplatesManager = {
if (mainFile == null) {
return
}
const rootDocId = await ProjectRootDocManager.setRootDocFromName(
const result = await ProjectRootDocManager.setRootDocFromName(
project._id,
mainFile
)
if (rootDocId) project.rootDoc_id = rootDocId
if (result) project.rootDoc_id = result.rootDocId
},
async fetchFromV1(templateId) {
@@ -91,11 +91,11 @@ async function createProjectFromZipArchiveWithName(
try {
const { fileEntries, docEntries } =
await _initializeProjectWithZipContents(ownerId, project, contentsPath)
const rootDocId =
const result =
await ProjectRootDocManager.promises.setRootDocAutomatically(
project._id
)
if (rootDocId) project.rootDoc_id = rootDocId
if (result) project.rootDoc_id = result.rootDocId
return { fileEntries, docEntries, project }
} catch (err) {
// no need to wait for the cleanup here
@@ -95,9 +95,12 @@ describe('CompileManager', function () {
describe('compile', function () {
beforeEach(function (ctx) {
ctx.CompileManager._checkIfRecentlyCompiled = sinon.stub().resolves(false)
ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet = sinon
ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid = sinon
.stub()
.resolves()
.resolves({
rootDocId: 'mock-root-doc-id-123',
rootResourcePath: '/main.tex',
})
ctx.CompileManager.promises.getProjectCompileLimits = sinon
.stub()
.resolves(ctx.limits)
@@ -108,7 +111,7 @@ describe('CompileManager', function () {
})
})
describe('succesfully', function () {
describe('successfully', function () {
let result
beforeEach(async function (ctx) {
ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
@@ -140,7 +143,7 @@ describe('CompileManager', function () {
})
it('should ensure that the root document is set', function (ctx) {
ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet
ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid
.calledWith(ctx.project_id)
.should.equal(true)
})
@@ -152,13 +155,17 @@ describe('CompileManager', function () {
})
it('should run the compile with the compile limits', function (ctx) {
ctx.ClsiManager.promises.sendRequest
.calledWith(ctx.project_id, ctx.user_id, {
ctx.ClsiManager.promises.sendRequest.should.have.been.calledWith(
ctx.project_id,
ctx.user_id,
{
timeout: ctx.limits.timeout,
compileGroup: 'standard',
buildId: sinon.match(/[a-f0-9]+-[a-f0-9]+/),
})
.should.equal(true)
rootResourcePath: 'main.tex',
rootDoc_id: 'mock-root-doc-id-123',
}
)
})
it('should resolve with the output', function (ctx) {
@@ -172,6 +179,37 @@ describe('CompileManager', function () {
})
})
describe('with rootResourcePath given', function () {
it('should not check the root doc', async function (ctx) {
const { status } = await ctx.CompileManager.promises.compile(
ctx.project_id,
ctx.user_id,
{ rootResourcePath: 'main.tex' }
)
ctx.ClsiManager.promises.sendRequest.should.have.been.called
status.should.equal('mock-status')
ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid.should.not
.have.been.called
})
})
describe('without a root doc', function () {
it('should return early', async function (ctx) {
ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid.resolves()
const { status, validationProblems } =
await ctx.CompileManager.promises.compile(
ctx.project_id,
ctx.user_id,
{}
)
status.should.equal('validation-problems')
validationProblems.should.deep.equal({
mainFile: 'no main file specified',
})
})
})
describe('when the project has been recently compiled', function () {
it('should return', async function (ctx) {
ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
@@ -27,12 +27,6 @@ describe('ExportsHandler', function () {
vi.doMock('@overleaf/fetch-utils', () => ctx.fetchUtils)
vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
default: (ctx.ProjectLocator = {
promises: {},
}),
}))
vi.doMock(
'../../../../app/src/Features/Project/ProjectRootDocManager',
() => ({
@@ -174,12 +168,9 @@ describe('ExportsHandler', function () {
ctx.ProjectHistoryHandler.promises.ensureHistoryExistsForProject = sinon
.stub()
.resolves()
ctx.ProjectLocator.promises.findRootDoc = sinon
.stub()
.resolves({ element: null, path: { fileSystem: 'main.tex' } })
ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid = sinon
.stub()
.resolves()
.resolves({ rootResourcePath: 'main.tex' })
ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.user)
ctx.ExportsHandler._requestVersion = sinon
.stub()
@@ -305,9 +296,9 @@ describe('ExportsHandler', function () {
let exportData
beforeEach(async function (ctx) {
ctx.project.rootDoc_id = null
ctx.ProjectLocator.promises.findRootDoc = sinon
ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid = sinon
.stub()
.resolves({ path: { fileSystem: 'other.tex' } })
.resolves({ rootResourcePath: 'other.tex' })
exportData = await ctx.ExportsHandler._buildExport(ctx.export_params)
})
@@ -361,9 +352,9 @@ describe('ExportsHandler', function () {
beforeEach(async function (ctx) {
ctx.fakeDoc_id = '1a2b3c4d5e6f'
ctx.project.rootDoc_id = ctx.fakeDoc_id
ctx.ProjectLocator.promises.findRootDoc = sinon
ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid = sinon
.stub()
.resolves({ path: { fileSystem: 'other.tex' } })
.resolves({ rootResourcePath: 'other.tex' })
exportData = await ctx.ExportsHandler._buildExport(ctx.export_params)
})
@@ -412,9 +403,9 @@ describe('ExportsHandler', function () {
describe('when no root doc can be identified', function () {
beforeEach(function (ctx) {
ctx.ProjectLocator.promises.findRootDoc = sinon
ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid = sinon
.stub()
.resolves({ element: null, path: null })
.resolves()
})
it('should return an error', async function (ctx) {
@@ -517,72 +517,6 @@ describe('ProjectRootDocManager', function () {
})
})
describe('ensureRootDocumentIsSet', function () {
beforeEach(function (ctx) {
ctx.project = {}
ctx.ProjectGetter.promises.getProject = sinon.stub().resolves(ctx.project)
ctx.ProjectRootDocManager.promises.setRootDocAutomatically = sinon
.stub()
.resolves()
})
describe('when the root doc is set', function () {
beforeEach(async function (ctx) {
ctx.project.rootDoc_id = ctx.docId2
await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet(
ctx.project_id
)
})
it('should find the project fetching only the rootDoc_id field', function (ctx) {
ctx.ProjectGetter.promises.getProject
.calledWith(ctx.project_id, { rootDoc_id: 1 })
.should.equal(true)
})
it('should not try to update the project rootDoc_id', function (ctx) {
ctx.ProjectRootDocManager.promises.setRootDocAutomatically.called.should.equal(
false
)
})
})
describe('when the root doc is not set', function () {
beforeEach(async function (ctx) {
await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet(
ctx.project_id
)
})
it('should find the project with only the rootDoc_id field', function (ctx) {
ctx.ProjectGetter.promises.getProject
.calledWith(ctx.project_id, { rootDoc_id: 1 })
.should.equal(true)
})
it('should update the project rootDoc_id', function (ctx) {
ctx.ProjectRootDocManager.promises.setRootDocAutomatically
.calledWith(ctx.project_id)
.should.equal(true)
})
})
describe('when the project does not exist', function () {
beforeEach(function (ctx) {
ctx.ProjectGetter.promises.getProject = sinon.stub().resolves(null)
})
it('should reject', async function (ctx) {
await expect(
ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet(
ctx.project_id
)
).to.be.rejectedWith('project not found')
})
})
})
describe('ensureRootDocumentIsValid', function () {
beforeEach(function (ctx) {
ctx.project = {}
@@ -601,14 +535,23 @@ describe('ProjectRootDocManager', function () {
describe('when the root doc is set', function () {
describe('when the root doc is valid', function () {
let result
beforeEach(async function (ctx) {
ctx.project.rootDoc_id = ctx.docId2
ctx.ProjectEntityHandler.promises.getDocPathFromProjectByDocId = sinon
.stub()
.resolves(ctx.docPaths[ctx.docId2])
await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid(
ctx.project_id
)
result =
await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid(
ctx.project_id
)
})
it('should return the path and id', ctx => {
result.should.deep.equal({
rootDocId: ctx.docId2,
rootResourcePath: ctx.docPaths[ctx.docId2],
})
})
it('should find the project without doc lines', function (ctx) {
@@ -625,14 +568,27 @@ describe('ProjectRootDocManager', function () {
})
describe('when the root doc is not valid', function () {
let result
beforeEach(async function (ctx) {
ctx.project.rootDoc_id = new ObjectId()
ctx.ProjectEntityHandler.promises.getDocPathFromProjectByDocId = sinon
.stub()
.resolves(null)
await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid(
ctx.project_id
ctx.automaticRootDoc = {
rootDocId: ctx.docId4,
rootResourcePath: ctx.docPaths[ctx.docId4],
}
ctx.ProjectRootDocManager.promises.setRootDocAutomatically.resolves(
ctx.automaticRootDoc
)
result =
await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid(
ctx.project_id
)
})
it('should return the path and id', ctx => {
result.should.deep.equal(ctx.automaticRootDoc)
})
it('should find the project without doc lines', function (ctx) {
@@ -656,10 +612,23 @@ describe('ProjectRootDocManager', function () {
})
describe('when the root doc is not set', function () {
let result
beforeEach(async function (ctx) {
await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid(
ctx.project_id
ctx.automaticRootDoc = {
rootDocId: ctx.docId4,
rootResourcePath: ctx.docPaths[ctx.docId4],
}
ctx.ProjectRootDocManager.promises.setRootDocAutomatically.resolves(
ctx.automaticRootDoc
)
result =
await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid(
ctx.project_id
)
})
it('should return the path and id', ctx => {
result.should.deep.equal(ctx.automaticRootDoc)
})
it('should find the project without doc lines', async function (ctx) {