Merge pull request #30053 from overleaf/acf-migration4-controllers-and-params

(4) Update controllers and tests for Zod migration

GitOrigin-RevId: 876fd64f96e1f5d7244ac1d45053c7db9857d46b
This commit is contained in:
Anna Claire Fields
2025-12-15 11:05:11 +01:00
committed by Copybot
parent 0efb28baa7
commit 65c164c73d
15 changed files with 687 additions and 198 deletions

View File

@@ -1,17 +1,17 @@
const { isZodErrorLike, fromError } = require('zod-validation-error') const { isZodErrorLike, fromError } = require('zod-validation-error')
/**
* @typedef {import('express').ErrorRequestHandler} ErrorRequestHandler
*/
const handleValidationError = [ function createHandleValidationError(statusCode = 400) {
/** @type {ErrorRequestHandler} */ return [
(err, req, res, next) => { (err, req, res, next) => {
if (!isZodErrorLike(err)) { if (!isZodErrorLike(err)) {
return next(err) return next(err)
} }
res.status(400).json({ ...fromError(err), statusCode: 400 }) res.status(statusCode).json({ ...fromError(err), statusCode })
}, },
] ]
}
module.exports = { handleValidationError } const handleValidationError = createHandleValidationError(400)
module.exports = { handleValidationError, createHandleValidationError }

View File

@@ -3,7 +3,10 @@ const { z } = require('zod')
const { zz } = require('./zodHelpers') const { zz } = require('./zodHelpers')
const { validateReq } = require('./validateReq') const { validateReq } = require('./validateReq')
const { validateSchema } = require('./validateSchema') const { validateSchema } = require('./validateSchema')
const { handleValidationError } = require('./handleValidationError') const {
handleValidationError,
createHandleValidationError,
} = require('./handleValidationError')
module.exports = { module.exports = {
z, z,
@@ -11,5 +14,6 @@ module.exports = {
validateSchema, validateSchema,
validateReq, validateReq,
handleValidationError, handleValidationError,
createHandleValidationError,
ParamsError, ParamsError,
} }

View File

@@ -1,10 +0,0 @@
/**
* Turn an async function into an Express middleware
*/
function expressify(fn) {
return (req, res, next) => {
fn(req, res, next).catch(next)
}
}
module.exports = expressify

View File

@@ -1,5 +1,5 @@
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const expressify = require('./expressify') const { expressify } = require('@overleaf/promise-utils')
const { mongodb } = require('../../storage') const { mongodb } = require('../../storage')
async function status(req, res) { async function status(req, res) {

View File

@@ -27,16 +27,36 @@ const persistBuffer = storage.persistBuffer
const InvalidChangeError = storage.InvalidChangeError const InvalidChangeError = storage.InvalidChangeError
const render = require('./render') const render = require('./render')
const { validateReq } = require('@overleaf/validation-tools')
const schemas = require('../schema')
const Rollout = require('../app/rollout') const Rollout = require('../app/rollout')
const redisBackend = require('../../storage/lib/chunk_store/redis') const redisBackend = require('../../storage/lib/chunk_store/redis')
const rollout = new Rollout(config) const rollout = new Rollout(config)
rollout.report(logger) // display the rollout configuration in the logs rollout.report(logger) // display the rollout configuration in the logs
function getParam(req, name, location = 'path') {
switch (location) {
case 'path':
return req.params?.[name]
case 'query':
return req.query?.[name]
case 'body':
if (name === 'body') {
return req.body
}
if (req.body?.[name] !== undefined) {
return req.body[name]
}
return undefined
default:
return undefined
}
}
async function importSnapshot(req, res) { async function importSnapshot(req, res) {
const projectId = req.swagger.params.project_id.value const { params, body } = validateReq(req, schemas.importSnapshot)
const rawSnapshot = req.swagger.params.snapshot.value const projectId = params.project_id
const rawSnapshot = getParam({ body }, 'snapshot', 'body') ?? body
let snapshot let snapshot
try { try {
@@ -62,10 +82,11 @@ async function importSnapshot(req, res) {
} }
async function importChanges(req, res, next) { async function importChanges(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params, query, body } = validateReq(req, schemas.importChanges)
const rawChanges = req.swagger.params.changes.value const projectId = params.project_id
const endVersion = req.swagger.params.end_version.value const rawChanges = getParam({ body }, 'changes', 'body') ?? body
const returnSnapshot = req.swagger.params.return_snapshot.value || 'none' const endVersion = query.end_version
const returnSnapshot = query.return_snapshot ?? 'none'
let changes let changes
@@ -156,7 +177,8 @@ async function importChanges(req, res, next) {
} }
async function flushChanges(req, res, next) { async function flushChanges(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.flushChanges)
const projectId = params.project_id
// Use the same limits importChanges, since these are passed to persistChanges // Use the same limits importChanges, since these are passed to persistChanges
const farFuture = new Date() const farFuture = new Date()
farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
@@ -179,7 +201,8 @@ async function flushChanges(req, res, next) {
} }
async function expireProject(req, res, next) { async function expireProject(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.expireProject)
const projectId = params.project_id
await redisBackend.expireProject(projectId) await redisBackend.expireProject(projectId)
res.status(HTTPStatus.OK).end() res.status(HTTPStatus.OK).end()
} }

View File

@@ -8,6 +8,8 @@ const fs = require('node:fs')
const { promisify } = require('node:util') const { promisify } = require('node:util')
const config = require('config') const config = require('config')
const OError = require('@overleaf/o-error') const OError = require('@overleaf/o-error')
const { expressify } = require('@overleaf/promise-utils')
const { validateReq } = require('@overleaf/validation-tools')
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const { Chunk, ChunkResponse, Blob } = require('overleaf-editor-core') const { Chunk, ChunkResponse, Blob } = require('overleaf-editor-core')
@@ -23,7 +25,7 @@ const {
} = require('../../storage') } = require('../../storage')
const render = require('./render') const render = require('./render')
const expressify = require('./expressify') const schemas = require('../schema')
const withTmpDir = require('./with_tmp_dir') const withTmpDir = require('./with_tmp_dir')
const StreamSizeLimit = require('./stream_size_limit') const StreamSizeLimit = require('./stream_size_limit')
const { getProjectBlobsBatch } = require('../../storage/lib/blob_store') const { getProjectBlobsBatch } = require('../../storage/lib/blob_store')
@@ -33,7 +35,8 @@ const { getChunkMetadataForVersion } = require('../../storage/lib/chunk_store')
const pipeline = promisify(Stream.pipeline) const pipeline = promisify(Stream.pipeline)
async function initializeProject(req, res, next) { async function initializeProject(req, res, next) {
let projectId = req.swagger.params.body.value.projectId const { body } = validateReq(req, schemas.initializeProject)
let projectId = body?.projectId
try { try {
projectId = await chunkStore.initializeProject(projectId) projectId = await chunkStore.initializeProject(projectId)
res.status(HTTPStatus.OK).json({ projectId }) res.status(HTTPStatus.OK).json({ projectId })
@@ -48,7 +51,8 @@ async function initializeProject(req, res, next) {
} }
async function getLatestContent(req, res, next) { async function getLatestContent(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.getLatestContent)
const projectId = params.project_id
const blobStore = new BlobStore(projectId) const blobStore = new BlobStore(projectId)
const chunk = await chunkStore.loadLatest(projectId) const chunk = await chunkStore.loadLatest(projectId)
const snapshot = chunk.getSnapshot() const snapshot = chunk.getSnapshot()
@@ -58,8 +62,9 @@ async function getLatestContent(req, res, next) {
} }
async function getContentAtVersion(req, res, next) { async function getContentAtVersion(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.getContentAtVersion)
const version = req.swagger.params.version.value const projectId = params.project_id
const version = params.version
const blobStore = new BlobStore(projectId) const blobStore = new BlobStore(projectId)
const snapshot = await getSnapshotAtVersion(projectId, version) const snapshot = await getSnapshotAtVersion(projectId, version)
await snapshot.loadFiles('eager', blobStore) await snapshot.loadFiles('eager', blobStore)
@@ -67,7 +72,8 @@ async function getContentAtVersion(req, res, next) {
} }
async function getLatestHashedContent(req, res, next) { async function getLatestHashedContent(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.getLatestHashedContent)
const projectId = params.project_id
const blobStore = new HashCheckBlobStore(new BlobStore(projectId)) const blobStore = new HashCheckBlobStore(new BlobStore(projectId))
const chunk = await chunkStore.loadLatest(projectId) const chunk = await chunkStore.loadLatest(projectId)
const snapshot = chunk.getSnapshot() const snapshot = chunk.getSnapshot()
@@ -78,7 +84,8 @@ async function getLatestHashedContent(req, res, next) {
} }
async function getLatestHistory(req, res, next) { async function getLatestHistory(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.getLatestHistory)
const projectId = params.project_id
try { try {
const chunk = await chunkStore.loadLatest(projectId) const chunk = await chunkStore.loadLatest(projectId)
const chunkResponse = new ChunkResponse(chunk) const chunkResponse = new ChunkResponse(chunk)
@@ -93,8 +100,9 @@ async function getLatestHistory(req, res, next) {
} }
async function getLatestHistoryRaw(req, res, next) { async function getLatestHistoryRaw(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params, query } = validateReq(req, schemas.getLatestHistoryRaw)
const readOnly = req.swagger.params.readOnly.value const projectId = params.project_id
const readOnly = query.readOnly
try { try {
const { startVersion, endVersion, endTimestamp } = const { startVersion, endVersion, endTimestamp } =
await chunkStore.getLatestChunkMetadata(projectId, { readOnly }) await chunkStore.getLatestChunkMetadata(projectId, { readOnly })
@@ -113,8 +121,9 @@ async function getLatestHistoryRaw(req, res, next) {
} }
async function getHistory(req, res, next) { async function getHistory(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.getHistory)
const version = req.swagger.params.version.value const projectId = params.project_id
const version = params.version
try { try {
const chunk = await chunkStore.loadAtVersion(projectId, version) const chunk = await chunkStore.loadAtVersion(projectId, version)
const chunkResponse = new ChunkResponse(chunk) const chunkResponse = new ChunkResponse(chunk)
@@ -129,8 +138,9 @@ async function getHistory(req, res, next) {
} }
async function getHistoryBefore(req, res, next) { async function getHistoryBefore(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.getHistoryBefore)
const timestamp = req.swagger.params.timestamp.value const projectId = params.project_id
const timestamp = params.timestamp
try { try {
const chunk = await chunkStore.loadAtTimestamp(projectId, timestamp) const chunk = await chunkStore.loadAtTimestamp(projectId, timestamp)
const chunkResponse = new ChunkResponse(chunk) const chunkResponse = new ChunkResponse(chunk)
@@ -148,8 +158,10 @@ async function getHistoryBefore(req, res, next) {
* Get all changes since the beginning of history or since a given version * Get all changes since the beginning of history or since a given version
*/ */
async function getChanges(req, res, next) { async function getChanges(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params, query } = validateReq(req, schemas.getChanges)
const since = req.swagger.params.since.value ?? 0 const projectId = params.project_id
const sinceParam = query.since
const since = sinceParam == null ? 0 : sinceParam
if (since < 0) { if (since < 0) {
// Negative values would cause an infinite loop // Negative values would cause an infinite loop
@@ -175,8 +187,9 @@ async function getChanges(req, res, next) {
} }
async function getZip(req, res, next) { async function getZip(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.getZip)
const version = req.swagger.params.version.value const projectId = params.project_id
const version = params.version
const blobStore = new BlobStore(projectId) const blobStore = new BlobStore(projectId)
let snapshot let snapshot
@@ -202,8 +215,9 @@ async function getZip(req, res, next) {
} }
async function createZip(req, res, next) { async function createZip(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.createZip)
const version = req.swagger.params.version.value const projectId = params.project_id
const version = params.version
try { try {
const snapshot = await getSnapshotAtVersion(projectId, version) const snapshot = await getSnapshotAtVersion(projectId, version)
const zipUrl = await zipStore.getSignedUrl(projectId, version) const zipUrl = await zipStore.getSignedUrl(projectId, version)
@@ -222,7 +236,8 @@ async function createZip(req, res, next) {
} }
async function deleteProject(req, res, next) { async function deleteProject(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.deleteProject)
const projectId = params.project_id
const blobStore = new BlobStore(projectId) const blobStore = new BlobStore(projectId)
await Promise.all([ await Promise.all([
@@ -234,8 +249,9 @@ async function deleteProject(req, res, next) {
} }
async function createProjectBlob(req, res, next) { async function createProjectBlob(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.createProjectBlob)
const expectedHash = req.swagger.params.hash.value const projectId = params.project_id
const expectedHash = params.hash
const maxUploadSize = parseInt(config.get('maxFileUploadSize'), 10) const maxUploadSize = parseInt(config.get('maxFileUploadSize'), 10)
await withTmpDir('blob-', async tmpDir => { await withTmpDir('blob-', async tmpDir => {
@@ -271,8 +287,9 @@ async function createProjectBlob(req, res, next) {
} }
async function headProjectBlob(req, res) { async function headProjectBlob(req, res) {
const projectId = req.swagger.params.project_id.value const { params } = validateReq(req, schemas.headProjectBlob)
const hash = req.swagger.params.hash.value const projectId = params.project_id
const hash = params.hash
const blobStore = new BlobStore(projectId) const blobStore = new BlobStore(projectId)
const blob = await blobStore.getBlob(hash) const blob = await blobStore.getBlob(hash)
@@ -283,7 +300,6 @@ async function headProjectBlob(req, res) {
res.status(404).end() res.status(404).end()
} }
} }
// Support simple, singular ranges starting from zero only, up-to 2MB = 2_000_000, 7 digits // Support simple, singular ranges starting from zero only, up-to 2MB = 2_000_000, 7 digits
const RANGE_HEADER = /^bytes=(\d{1,7})-(\d{1,7})$/ const RANGE_HEADER = /^bytes=(\d{1,7})-(\d{1,7})$/
@@ -304,13 +320,19 @@ function _getRangeOpts(header) {
} }
async function getProjectBlob(req, res, next) { async function getProjectBlob(req, res, next) {
const projectId = req.swagger.params.project_id.value const { params, headers } = validateReq(req, schemas.getProjectBlob)
const hash = req.swagger.params.hash.value const projectId = params.project_id
const opts = _getRangeOpts(req.swagger.params.range.value || '') const hash = params.hash
const rangeHeader = headers.range || ''
const opts = _getRangeOpts(rangeHeader)
const blobStore = new BlobStore(projectId) const blobStore = new BlobStore(projectId)
logger.debug({ projectId, hash }, 'getProjectBlob started') logger.debug({ projectId, hash }, 'getProjectBlob started')
try { try {
if (req.method === 'HEAD') {
return await headProjectBlob(req, res)
}
let stream let stream
try { try {
if (opts) { if (opts) {
@@ -363,9 +385,10 @@ async function getProjectBlob(req, res, next) {
} }
async function copyProjectBlob(req, res, next) { async function copyProjectBlob(req, res, next) {
const sourceProjectId = req.swagger.params.copyFrom.value const { params, query } = validateReq(req, schemas.copyProjectBlob)
const targetProjectId = req.swagger.params.project_id.value const sourceProjectId = query.copyFrom
const blobHash = req.swagger.params.hash.value const targetProjectId = params.project_id
const blobHash = params.hash
// Check that blob exists in source project // Check that blob exists in source project
const sourceBlobStore = new BlobStore(sourceProjectId) const sourceBlobStore = new BlobStore(sourceProjectId)
const targetBlobStore = new BlobStore(targetProjectId) const targetBlobStore = new BlobStore(targetProjectId)
@@ -427,8 +450,9 @@ function sumUpByteLength(blobs) {
} }
async function getBlobStats(req, res) { async function getBlobStats(req, res) {
const projectId = req.swagger.params.project_id.value const { params, body } = validateReq(req, schemas.getBlobStats)
const blobHashes = req.swagger.params.body.value.blobHashes || [] const projectId = params.project_id
const blobHashes = body.blobHashes || []
for (const hash of blobHashes) { for (const hash of blobHashes) {
assert.blobHash(hash, 'bad hash') assert.blobHash(hash, 'bad hash')
} }
@@ -451,7 +475,8 @@ async function getBlobStats(req, res) {
} }
async function getProjectBlobsStats(req, res) { async function getProjectBlobsStats(req, res) {
const projectIds = req.swagger.params.body.value.projectIds const { body } = validateReq(req, schemas.getProjectBlobsStats)
const projectIds = body.projectIds
const { blobs } = await getProjectBlobsBatch( const { blobs } = await getProjectBlobsBatch(
projectIds.map(id => { projectIds.map(id => {
if (assert.POSTGRES_ID_REGEXP.test(id)) { if (assert.POSTGRES_ID_REGEXP.test(id)) {

View File

@@ -48,6 +48,19 @@ function setupSSL(app) {
exports.setupSSL = setupSSL exports.setupSSL = setupSSL
function setupBasicHttpAuthForSwaggerDocs(app) {
app.use('/docs', function (req, res, next) {
if (hasValidBasicAuthCredentials(req)) {
return next()
}
res.header('WWW-Authenticate', 'Basic realm="Application"')
res.status(HTTPStatus.UNAUTHORIZED).end()
})
}
exports.setupBasicHttpAuthForSwaggerDocs = setupBasicHttpAuthForSwaggerDocs
function configureJWTAuth(mode = 'jwt') { function configureJWTAuth(mode = 'jwt') {
return function handleJWTAuth(req, res, next) { return function handleJWTAuth(req, res, next) {
if (hasValidBasicAuthCredentials(req)) { if (hasValidBasicAuthCredentials(req)) {

View File

@@ -1,6 +1,6 @@
'use strict' 'use strict'
const { z } = require('@overleaf/validation-tools') const { z, zz } = require('@overleaf/validation-tools')
const Blob = require('overleaf-editor-core').Blob const Blob = require('overleaf-editor-core').Blob
const hexHashPattern = new RegExp(Blob.HEX_HASH_RX_STRING) const hexHashPattern = new RegExp(Blob.HEX_HASH_RX_STRING)
@@ -20,26 +20,38 @@ const v2DocVersionsSchema = z.object({
v: z.number().int().optional(), v: z.number().int().optional(),
}) })
const operationSchema = z.object({ const operationSchema = z
pathname: z.string().optional(), .object({
newPathname: z.string().optional(), pathname: z.string().optional(),
blob: z newPathname: z.string().optional(),
.object({ blob: z
hash: z.string(), .object({
}) hash: z.string(),
.optional(), })
textOperation: z.array(z.any()).optional(), .optional(),
file: fileSchema.optional(), textOperation: z.array(z.any()).optional(),
}) file: fileSchema.optional(),
contentHash: z.string().optional(),
})
.passthrough()
const changeSchema = z.object({ const originSchema = z
timestamp: z.string(), .object({
operations: z.array(operationSchema), kind: z.string().optional(),
authors: z.array(z.number().int().nullable()).optional(), })
v2Authors: z.array(z.string().nullable()).optional(), .passthrough()
projectVersion: z.string().optional(),
v2DocVersions: z.record(v2DocVersionsSchema).optional(), const changeSchema = z
}) .object({
timestamp: z.string(),
operations: z.array(operationSchema),
authors: z.array(z.number().int().nullable()).optional(),
v2Authors: z.array(z.string().nullable()).optional(),
origin: originSchema.optional(),
projectVersion: z.string().optional(),
v2DocVersions: z.record(z.string(), v2DocVersionsSchema).optional(),
})
.passthrough()
const schemas = { const schemas = {
projectId: z.object({ projectId: z.object({
@@ -138,7 +150,7 @@ const schemas = {
project_id: z.string(), project_id: z.string(),
}), }),
query: z.object({ query: z.object({
readOnly: z.boolean().optional(), readOnly: z.coerce.boolean().optional(),
}), }),
}), }),
@@ -165,7 +177,7 @@ const schemas = {
getHistoryBefore: z.object({ getHistoryBefore: z.object({
params: z.object({ params: z.object({
project_id: z.string(), project_id: z.string(),
timestamp: z.iso.datetime(), timestamp: zz.datetime(),
}), }),
}), }),

View File

@@ -7,24 +7,27 @@ require('@overleaf/metrics/initialize')
const config = require('config') const config = require('config')
const Events = require('node:events') const Events = require('node:events')
const BPromise = require('bluebird')
const express = require('express') const express = require('express')
const helmet = require('helmet') const helmet = require('helmet')
const HTTPStatus = require('http-status') const HTTPStatus = require('http-status')
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const Metrics = require('@overleaf/metrics') const Metrics = require('@overleaf/metrics')
const bodyParser = require('body-parser') const bodyParser = require('body-parser')
const swaggerTools = require('swagger-tools') const security = require('./api/middleware/security')
const swaggerDoc = require('./api/swagger')
const security = require('./api/app/security')
const healthChecks = require('./api/controllers/health_checks') const healthChecks = require('./api/controllers/health_checks')
const { mongodb, loadGlobalBlobs } = require('./storage') const { mongodb, loadGlobalBlobs } = require('./storage')
const path = require('node:path') const projectsRoutes = require('./api/routes/projects')
const projectImportRoutes = require('./api/routes/project_import')
const { createHandleValidationError } = require('@overleaf/validation-tools')
Events.setMaxListeners(20) Events.setMaxListeners(20)
const app = express() const app = express()
module.exports = app module.exports = app
const handleValidationError = createHandleValidationError(
HTTPStatus.UNPROCESSABLE_ENTITY
)
logger.initialize('history-v1') logger.initialize('history-v1')
Metrics.open_sockets.monitor() Metrics.open_sockets.monitor()
Metrics.injectMetricsRoute(app) Metrics.injectMetricsRoute(app)
@@ -57,23 +60,9 @@ app.get('/', function (req, res) {
app.get('/status', healthChecks.status) app.get('/status', healthChecks.status)
app.get('/health_check', healthChecks.healthCheck) app.get('/health_check', healthChecks.healthCheck)
function setupSwagger() { app.get('/docs', function (req, res) {
return new BPromise(function (resolve) { res.send('OK')
swaggerTools.initializeMiddleware(swaggerDoc, function (middleware) { })
app.use(middleware.swaggerMetadata())
app.use(middleware.swaggerSecurity(security.getSwaggerHandlers()))
app.use(middleware.swaggerValidator())
app.use(
middleware.swaggerRouter({
controllers: path.join(__dirname, 'api/controllers'),
useStubs: app.get('env') === 'development',
})
)
app.use(middleware.swaggerUi())
resolve()
})
})
}
function setupErrorHandling() { function setupErrorHandling() {
app.use(function (req, res, next) { app.use(function (req, res, next) {
@@ -82,40 +71,10 @@ function setupErrorHandling() {
return next(err) return next(err)
}) })
// Handle Swagger errors. app.use(handleValidationError)
app.use(function (err, req, res, next) {
const projectId = req.swagger?.params?.project_id?.value
if (res.headersSent) {
return next(err)
}
if (err.code === 'SCHEMA_VALIDATION_FAILED') {
logger.error({ err, projectId }, err.message)
return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json(err.results)
}
if (err.code === 'INVALID_TYPE' || err.code === 'PATTERN') {
logger.error({ err, projectId }, err.message)
return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json({
message: 'invalid type: ' + err.paramName,
})
}
if (err.code === 'ENUM_MISMATCH') {
logger.warn({ err, projectId }, err.message)
return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json({
message: 'invalid enum value: ' + err.paramName,
})
}
if (err.code === 'REQUIRED') {
logger.warn({ err, projectId }, err.message)
return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json({
message: err.message,
})
}
next(err)
})
app.use(function (err, req, res, next) { app.use(function (err, req, res, next) {
const projectId = req.swagger?.params?.project_id?.value const projectId = req.params?.project_id || req.body?.projectId
logger.error({ err, projectId }, err.message) logger.error({ err, projectId }, err.message)
if (res.headersSent) { if (res.headersSent) {
@@ -127,6 +86,10 @@ function setupErrorHandling() {
// 200, notably some InternalErrors and TimeoutErrors, so we have to guard // 200, notably some InternalErrors and TimeoutErrors, so we have to guard
// against that. We also check `status`, but `statusCode` is preferred. // against that. We also check `status`, but `statusCode` is preferred.
const statusCode = err.statusCode || err.status const statusCode = err.statusCode || err.status
if (err.headers) {
res.set(err.headers)
}
if (statusCode && statusCode >= 400 && statusCode < 600) { if (statusCode && statusCode >= 400 && statusCode < 600) {
res.status(statusCode) res.status(statusCode)
} else { } else {
@@ -147,7 +110,8 @@ app.setup = async function appSetup() {
await loadGlobalBlobs() await loadGlobalBlobs()
logger.info('Global blobs loaded') logger.info('Global blobs loaded')
app.use(helmet()) app.use(helmet())
await setupSwagger() app.use('/api', projectsRoutes)
app.use('/api', projectImportRoutes)
setupErrorHandling() setupErrorHandling()
} }

View File

@@ -7,7 +7,7 @@ import { promisify } from 'node:util'
import express from 'express' import express from 'express'
import logger from '@overleaf/logger' import logger from '@overleaf/logger'
import Metrics from '@overleaf/metrics' import Metrics from '@overleaf/metrics'
import { hasValidBasicAuthCredentials } from './api/app/security.js' import { hasValidBasicAuthCredentials } from './api/middleware/security.js'
import { import {
deleteProjectBackupCb, deleteProjectBackupCb,
healthCheck, healthCheck,

View File

@@ -1,12 +1,11 @@
const config = require('config') const config = require('config')
const fetch = require('node-fetch')
const sinon = require('sinon') const sinon = require('sinon')
const { expect } = require('chai') const { expect } = require('chai')
const nodeFetch = require('node-fetch')
const cleanup = require('../storage/support/cleanup') const cleanup = require('../storage/support/cleanup')
const expectResponse = require('./support/expect_response') const expectResponse = require('./support/expect_response')
const fixtures = require('../storage/support/fixtures') const fixtures = require('../storage/support/fixtures')
const HTTPStatus = require('http-status')
const testServer = require('./support/test_server') const testServer = require('./support/test_server')
describe('auth', function () { describe('auth', function () {
@@ -18,47 +17,26 @@ describe('auth', function () {
}) })
afterEach(sinon.restore) afterEach(sinon.restore)
it('renders 401 on swagger docs endpoint without auth', async function () { it('protects /docs with basic auth', async function () {
const response = await fetch(testServer.url('/docs')) const url = testServer.url('/docs')
expect(response.status).to.equal(HTTPStatus.UNAUTHORIZED)
expect(response.headers.get('www-authenticate')).to.match(/^Basic/)
})
it('renders swagger docs endpoint with auth', async function () { const unauthenticatedResponse = await nodeFetch(url)
const response = await fetch(testServer.url('/docs'), { expect(unauthenticatedResponse.status).to.equal(401)
headers: { expect(unauthenticatedResponse.headers.get('www-authenticate')).to.match(
Authorization: testServer.basicAuthHeader, /^Basic/
}, )
const badHeader =
'Basic ' + Buffer.from('staging:wrong-password').toString('base64')
const badPasswordResponse = await nodeFetch(url, {
headers: { Authorization: badHeader },
}) })
expect(response.ok).to.be.true expect(badPasswordResponse.status).to.equal(401)
})
it('takes an old basic auth password during a password change', async function () { const validResponse = await nodeFetch(url, {
setMockConfig('basicHttpAuth.oldPassword', 'foo') headers: { Authorization: testServer.basicAuthHeader },
// Primary should still work.
const response1 = await fetch(testServer.url('/docs'), {
headers: {
Authorization: testServer.basicAuthHeader,
},
}) })
expect(response1.ok).to.be.true expect(validResponse.status).to.equal(200)
// Old password should also work.
const response2 = await fetch(testServer.url('/docs'), {
headers: {
Authorization: 'Basic ' + Buffer.from('staging:foo').toString('base64'),
},
})
expect(response2.ok).to.be.true
// Incorrect password should not work.
const response3 = await fetch(testServer.url('/docs'), {
header: {
Authorization: 'Basic ' + Buffer.from('staging:bar').toString('base64'),
},
})
expect(response3.status).to.equal(HTTPStatus.UNAUTHORIZED)
}) })
it('renders 401 on ProjectImport endpoints', async function () { it('renders 401 on ProjectImport endpoints', async function () {

View File

@@ -449,6 +449,70 @@ describe('history import', function () {
.catch(expectResponse.unprocessableEntity) .catch(expectResponse.unprocessableEntity)
}) })
it('imports changes with git-bridge origin', function () {
const testProjectId = '1'
const testFilePathname = 'git.tex'
const testFile = File.fromHash(File.EMPTY_FILE_HASH)
const testGitOrigin = Origin.fromRaw({
kind: 'git-bridge',
})
let testSnapshot
return fetch(
testServer.url(
`/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
),
{
method: 'PUT',
body: fs.createReadStream(testFiles.path('empty.tex')),
headers: {
Authorization: testServer.basicAuthHeader,
},
}
)
.then(response => {
expect(response.ok).to.be.true
})
.then(() => {
testSnapshot = new Snapshot()
testSnapshot.addFile(testFilePathname, testFile)
return basicAuthClient.apis.ProjectImport.importSnapshot1({
project_id: testProjectId,
snapshot: testSnapshot.toRaw(),
})
})
.then(response => {
expect(response.obj.projectId).to.equal(testProjectId)
})
.then(() => {
const changes = [
makeChange(Operation.addFile(testFilePathname, testFile)),
]
changes[0].setOrigin(testGitOrigin)
return basicAuthClient.apis.ProjectImport.importChanges1({
project_id: testProjectId,
end_version: 0,
changes: changes.map(changeToRaw),
})
})
.then(response => {
expect(response.status).to.equal(HTTPStatus.CREATED)
})
.then(() => {
return clientForProject.apis.Project.getLatestHistory({
project_id: testProjectId,
})
})
.then(response => {
const chunkResponse = ChunkResponse.fromRaw(response.obj)
const changes = chunkResponse.getChunk().getChanges()
expect(changes.length).to.be.at.least(1)
const lastChange = changes[changes.length - 1]
expect(lastChange.getOrigin()).to.deep.equal(testGitOrigin)
})
})
it('rejects text operations on binary files', function () { it('rejects text operations on binary files', function () {
const testProjectId = '1' const testProjectId = '1'
const testFilePathname = 'main.tex' const testFilePathname = 'main.tex'
@@ -821,9 +885,7 @@ describe('history import', function () {
expect.fail() expect.fail()
}) })
.catch(error => { .catch(error => {
expect(error.message).to.equal( expect(error.message).to.equal('request failed with status 422')
'Required parameter end_version is not provided'
)
}) })
}) })
@@ -845,9 +907,6 @@ describe('history import', function () {
}) })
.catch(error => { .catch(error => {
expect(error.status).to.equal(HTTPStatus.UNPROCESSABLE_ENTITY) expect(error.status).to.equal(HTTPStatus.UNPROCESSABLE_ENTITY)
expect(error.response.body.message).to.equal(
'invalid enum value: return_snapshot'
)
}) })
}) })
}) })

View File

@@ -520,7 +520,7 @@ describe('project controller', function () {
project_id: projectId, project_id: projectId,
since: -1, since: -1,
}) })
).to.be.rejectedWith('Bad Request') ).to.be.rejectedWith('request failed with status 400')
}) })
it('rejects out of bounds versions', async function () { it('rejects out of bounds versions', async function () {
@@ -529,7 +529,7 @@ describe('project controller', function () {
project_id: projectId, project_id: projectId,
since: 20, since: 20,
}) })
).to.be.rejectedWith('Bad Request') ).to.be.rejectedWith('request failed with status 400')
}) })
}) })

View File

@@ -0,0 +1,423 @@
'use strict'
const nodeFetch = require('node-fetch')
const {
fetchJsonWithResponse,
fetchStreamWithResponse,
RequestFailedError,
} = require('@overleaf/fetch-utils')
/**
* Create an HTTP client that mimics the swagger-client API
* @param {string} baseUrl - Base URL for the API
* @param {Object} options - Options including authorizations
* @returns {Object} Client with apis.Project and apis.ProjectImport methods
*/
function createHttpClient(baseUrl, options = {}) {
const authHeaders = {}
const queryParams = {}
// Handle different auth types
if (options.authorizations) {
if (options.authorizations.jwt) {
if (options.authorizations.jwt.startsWith('Bearer ')) {
authHeaders.Authorization = options.authorizations.jwt
} else if (options.authorizations.jwt.startsWith('Basic ')) {
authHeaders.Authorization = options.authorizations.jwt
} else {
authHeaders.Authorization = `Bearer ${options.authorizations.jwt}`
}
}
if (options.authorizations.basic) {
const { username, password } = options.authorizations.basic
const credentials = Buffer.from(`${username}:${password}`).toString(
'base64'
)
authHeaders.Authorization = `Basic ${credentials}`
}
if (options.authorizations.token) {
queryParams.token = options.authorizations.token
}
}
function makeRequest(method, path, params = {}) {
// Build URL with path params
// Ensure we don't have double slashes between baseUrl and path
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
const pathStart = path.startsWith('/') ? path : `/${path}`
let url = `${base}${pathStart}`
Object.keys(params).forEach(key => {
if (
key !== 'body' &&
key !== 'since' &&
key !== 'end_version' &&
key !== 'return_snapshot' &&
key !== 'snapshot' &&
key !== 'changes'
) {
const value = params[key]
if (value !== undefined && value !== null) {
url = url.replace(`:${key}`, String(value))
}
}
})
// Build query string
const query = new URLSearchParams()
if (params.since !== undefined) {
query.append('since', params.since)
}
if (params.end_version !== undefined) {
query.append('end_version', params.end_version)
}
if (params.return_snapshot) {
query.append('return_snapshot', params.return_snapshot)
}
Object.keys(queryParams).forEach(key => {
query.append(key, queryParams[key])
})
if (query.toString()) {
url += `?${query.toString()}`
}
// Build headers
const headers = { ...authHeaders }
let body = null
if (params.body) {
body = JSON.stringify(params.body)
headers['Content-Type'] = 'application/json'
} else if (method === 'POST' || method === 'PUT') {
// Some endpoints have the entire body as the parameter
if (params.snapshot !== undefined) {
body = JSON.stringify(params.snapshot)
headers['Content-Type'] = 'application/json'
} else if (params.changes !== undefined) {
body = JSON.stringify(params.changes)
headers['Content-Type'] = 'application/json'
}
}
const fetchOpts = {
method,
headers,
body,
}
const isNoContentEndpoint =
(method === 'POST' && url.includes('/flush')) ||
(method === 'POST' && url.includes('/expire')) ||
(method === 'DELETE' &&
url.includes('/api/projects/') &&
!url.includes('/blobs/'))
const isBlobEndpoint =
method === 'GET' && (url.includes('/blobs/') || url.includes('/zip'))
if (isNoContentEndpoint) {
return nodeFetch(url, fetchOpts).then(response => {
const headersObj = {}
response.headers.forEach((value, key) => {
headersObj[key.toLowerCase()] = value
})
return {
obj: null,
body: null,
status: response.status,
headers: headersObj,
ok: response.ok,
}
})
}
if (isBlobEndpoint) {
return fetchStreamWithResponse(url, fetchOpts)
.then(({ stream, response }) => {
const headersObj = {}
response.headers.forEach((value, key) => {
headersObj[key.toLowerCase()] = value
})
let cachedBuffer = null
const bufferPromise = (async () => {
if (cachedBuffer === null) {
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
cachedBuffer = Buffer.concat(chunks)
}
return cachedBuffer
})()
return {
obj: null,
status: response.status,
headers: headersObj,
data: {
text: async () => {
const buffer = await bufferPromise
return buffer.toString()
},
json: async () => {
const buffer = await bufferPromise
return JSON.parse(buffer.toString())
},
arrayBuffer: async () => {
const buffer = await bufferPromise
return Uint8Array.from(buffer).buffer
},
},
ok: response.ok,
}
})
.catch(err => {
if (err instanceof RequestFailedError) {
// Convert Headers object to plain object for compatibility with tests
const headersObj = {}
err.response.headers.forEach((value, key) => {
headersObj[key.toLowerCase()] = value
})
// Use the server's error message if available, otherwise use the generic message
let errorMessage = err.message
let errorObj = err.body
if (err.body) {
try {
const bodyObj =
typeof err.body === 'string' ? JSON.parse(err.body) : err.body
errorObj = bodyObj // Store parsed object for error.obj
if (bodyObj?.message) {
errorMessage = bodyObj.message
} else if (typeof bodyObj === 'string' && bodyObj.trim()) {
errorMessage = bodyObj
}
} catch (e) {
// If body isn't JSON, use the string as-is or fall back to generic message
if (typeof err.body === 'string' && err.body.trim()) {
errorMessage = err.body
errorObj = err.body
}
}
}
// If we still have the generic message, include status code for better debugging
if (errorMessage === 'request failed' && err.response?.status) {
errorMessage = `request failed with status ${err.response.status}`
}
const error = new Error(errorMessage)
error.status = err.response.status
error.statusCode = err.response.status
error.obj = errorObj
error.response = {
status: err.response.status,
statusCode: err.response.status,
headers: headersObj, // Plain object for err.response.headers access
ok: err.response.ok,
}
throw error
}
throw err
})
}
// For JSON endpoints, use fetchJsonWithResponse but handle errors ourselves
return fetchJsonWithResponse(url, fetchOpts)
.then(({ json, response }) => {
// Convert Headers object to plain object for compatibility
const headersObj = {}
response.headers.forEach((value, key) => {
headersObj[key.toLowerCase()] = value
})
return {
obj: json,
body: json,
status: response.status,
headers: headersObj, // Plain object for compatibility
ok: response.ok,
}
})
.catch(err => {
if (err instanceof RequestFailedError) {
// Convert Headers object to plain object for compatibility with tests
const headersObj = {}
err.response.headers.forEach((value, key) => {
headersObj[key.toLowerCase()] = value
})
// Preserve the response object structure that tests expect
let errorMessage = err.message
let errorObj = err.body
// If we still have the generic message, include status code for better debugging
if (errorMessage === 'request failed' && err.response?.status) {
errorMessage = `request failed with status ${err.response.status}`
}
if (typeof errorObj === 'string') {
try {
errorObj = JSON.parse(errorObj)
} catch (e) {
// leave it as string if it isn't JSON
}
}
const status = err.response.status
const error = new Error(errorMessage)
error.status = status
error.statusCode = status
error.obj = errorObj
error.response = {
status,
statusCode: status,
headers: headersObj, // Plain object for err.response.headers['www-authenticate'] access
ok: err.response.ok,
body: errorObj,
}
throw error
}
// Handle invalid JSON responses (e.g. empty body with 200/204)
if (
err?.name === 'FetchError' &&
(err?.type === 'invalid-json' ||
(typeof err.message === 'string' &&
err.message.indexOf('invalid json response body') !== -1))
) {
const response = err.response
if (response) {
const headersObj = {}
response.headers.forEach((value, key) => {
headersObj[key.toLowerCase()] = value
})
if (!response.ok) {
const error = new Error(
`request failed with status ${response.status}`
)
error.status = response.status
error.statusCode = response.status
error.obj = null
error.response = {
status: response.status,
statusCode: response.status,
headers: headersObj,
ok: response.ok,
}
throw error
}
return {
obj: null,
body: null,
status: response.status,
headers: headersObj,
ok: response.ok,
}
}
}
throw err
})
}
return {
apis: {
Project: {
initializeProject: params =>
makeRequest('POST', '/api/projects', params),
getProjectBlobsStats: params =>
makeRequest('POST', '/api/projects/blob-stats', params),
getBlobStats: params =>
makeRequest('POST', `/api/projects/:project_id/blob-stats`, params),
deleteProject: params =>
makeRequest('DELETE', `/api/projects/:project_id`, params),
getProjectBlob: params =>
makeRequest('GET', `/api/projects/:project_id/blobs/:hash`, params),
headProjectBlob: params =>
makeRequest('HEAD', `/api/projects/:project_id/blobs/:hash`, params),
createProjectBlob: params =>
makeRequest('PUT', `/api/projects/:project_id/blobs/:hash`, params),
copyProjectBlob: params =>
makeRequest('POST', `/api/projects/:project_id/blobs/:hash`, params),
getLatestContent: params =>
makeRequest(
'GET',
`/api/projects/:project_id/latest/content`,
params
),
getLatestHashedContent: params =>
makeRequest(
'GET',
`/api/projects/:project_id/latest/hashed_content`,
params
),
getLatestHistory: params =>
makeRequest(
'GET',
`/api/projects/:project_id/latest/history`,
params
),
getLatestHistoryRaw: params =>
makeRequest(
'GET',
`/api/projects/:project_id/latest/history/raw`,
params
),
getLatestPersistedHistory: params =>
makeRequest(
'GET',
`/api/projects/:project_id/latest/persistedHistory`,
params
),
getHistory: params =>
makeRequest(
'GET',
`/api/projects/:project_id/versions/:version/history`,
params
),
getContentAtVersion: params =>
makeRequest(
'GET',
`/api/projects/:project_id/versions/:version/content`,
params
),
getHistoryBefore: params =>
makeRequest(
'GET',
`/api/projects/:project_id/timestamp/:timestamp/history`,
params
),
getZip: params =>
makeRequest(
'GET',
`/api/projects/:project_id/version/:version/zip`,
params
),
createZip: params =>
makeRequest(
'POST',
`/api/projects/:project_id/version/:version/zip`,
params
),
getChanges: params =>
makeRequest('GET', `/api/projects/:project_id/changes`, params),
},
ProjectImport: {
importSnapshot1: params =>
makeRequest('POST', `/api/projects/:project_id/import`, params),
importChanges1: params =>
makeRequest('POST', `/api/projects/:project_id/changes`, params),
flushChanges: params =>
makeRequest('POST', `/api/projects/:project_id/flush`, params),
expireProject: params =>
makeRequest('POST', `/api/projects/:project_id/expire`, params),
},
},
}
}
module.exports = createHttpClient

View File

@@ -11,7 +11,7 @@ const config = require('config')
const http = require('node:http') const http = require('node:http')
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const Swagger = require('swagger-client') const createHttpClient = require('./http_client')
const app = require('../../../../../app') const app = require('../../../../../app')
@@ -28,10 +28,8 @@ function testUrl(pathname, opts = {}) {
exports.url = testUrl exports.url = testUrl
function createClient(options) { function createClient(options) {
// The Swagger client returns native Promises; we use Bluebird promises. Just // Create HTTP client that mimics swagger-client API
// wrapping the client creation is enough in many (but not all) cases to return BPromise.resolve(createHttpClient(testUrl(''), options))
// get Bluebird into the chain.
return BPromise.resolve(new Swagger(testUrl('/api-docs'), options))
} }
function createTokenForProject(projectId, opts = {}) { function createTokenForProject(projectId, opts = {}) {