mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
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:
committed by
Copybot
parent
0efb28baa7
commit
65c164c73d
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 = {}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user