mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[history-v1] adopt strict zod types and extend test coverage (#36089)
GitOrigin-RevId: 7bdffe2c05c7c17be6eb3e4296d8df05d650c53e
This commit is contained in:
@@ -597,8 +597,7 @@ const renameUpdateSchema = z.strictObject({
|
|||||||
id: zz.objectId(),
|
id: zz.objectId(),
|
||||||
// forwarded (via project-history) into history-v1's archive/zip builder
|
// forwarded (via project-history) into history-v1's archive/zip builder
|
||||||
// (project_archive.js `archive.append(content, { name: pathname })`), so a
|
// (project_archive.js `archive.append(content, { name: pathname })`), so a
|
||||||
// traversal payload here is a zip-slip risk, not just a display string --
|
// traversal payload here is a zip-slip risk, not just a display string.
|
||||||
// see zod-string-field-hardening.md, category C.
|
|
||||||
pathname: zz.safePath(),
|
pathname: zz.safePath(),
|
||||||
// deletes are renames to an empty newPathname (see the file comment
|
// deletes are renames to an empty newPathname (see the file comment
|
||||||
// above), so the empty string is a legitimate sentinel here, not a
|
// above), so the empty string is a legitimate sentinel here, not a
|
||||||
|
|||||||
@@ -38,32 +38,15 @@ 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 { params, body } = parseReq(req, schemas.importSnapshot)
|
const { params, body } = parseReq(req, schemas.importSnapshot, {
|
||||||
|
fallbackSchema: schemas.importSnapshotFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const rawSnapshot = getParam({ body }, 'snapshot', 'body') ?? body
|
|
||||||
let snapshot
|
let snapshot
|
||||||
|
|
||||||
try {
|
try {
|
||||||
snapshot = Snapshot.fromRaw(rawSnapshot)
|
snapshot = Snapshot.fromRaw(body)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err, projectId }, 'failed to import snapshot')
|
logger.warn({ err, projectId }, 'failed to import snapshot')
|
||||||
return render.unprocessableEntity(res)
|
return render.unprocessableEntity(res)
|
||||||
@@ -96,16 +79,17 @@ function getPersistLimits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function importChanges(req, res, next) {
|
async function importChanges(req, res, next) {
|
||||||
const { params, query, body } = parseReq(req, schemas.importChanges)
|
const { params, query, body } = parseReq(req, schemas.importChanges, {
|
||||||
|
fallbackSchema: schemas.importChangesFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const rawChanges = getParam({ body }, 'changes', 'body') ?? body
|
|
||||||
const endVersion = query.end_version
|
const endVersion = query.end_version
|
||||||
const returnSnapshot = query.return_snapshot ?? 'none'
|
const returnSnapshot = query.return_snapshot ?? 'none'
|
||||||
|
|
||||||
let changes
|
let changes
|
||||||
|
|
||||||
try {
|
try {
|
||||||
changes = rawChanges.map(Change.fromRaw)
|
changes = body.map(rawChange => Change.mustFromRaw(rawChange))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err, projectId }, 'failed to parse changes')
|
logger.warn({ err, projectId }, 'failed to parse changes')
|
||||||
return render.unprocessableEntity(res)
|
return render.unprocessableEntity(res)
|
||||||
@@ -236,7 +220,9 @@ async function setContent(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function flushChanges(req, res, next) {
|
async function flushChanges(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.flushChanges)
|
const { params } = parseReq(req, schemas.flushChanges, {
|
||||||
|
fallbackSchema: schemas.flushChangesFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
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 limits = {
|
const limits = {
|
||||||
@@ -256,7 +242,9 @@ async function flushChanges(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expireProject(req, res, next) {
|
async function expireProject(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.expireProject)
|
const { params } = parseReq(req, schemas.expireProject, {
|
||||||
|
fallbackSchema: schemas.expireProjectFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
await redisBackend.expireProject(projectId)
|
await redisBackend.expireProject(projectId)
|
||||||
res.status(HTTPStatus.OK).end()
|
res.status(HTTPStatus.OK).end()
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ const { IncrementalResponse } = require('@overleaf/stream-utils')
|
|||||||
const pipeline = promisify(Stream.pipeline)
|
const pipeline = promisify(Stream.pipeline)
|
||||||
|
|
||||||
async function initializeProject(req, res, next) {
|
async function initializeProject(req, res, next) {
|
||||||
const { body } = parseReq(req, schemas.initializeProject)
|
const { body } = parseReq(req, schemas.initializeProject, {
|
||||||
|
fallbackSchema: schemas.initializeProjectFallbackSchema,
|
||||||
|
})
|
||||||
let projectId = body?.projectId
|
let projectId = body?.projectId
|
||||||
try {
|
try {
|
||||||
projectId = await chunkStore.initializeProject(projectId)
|
projectId = await chunkStore.initializeProject(projectId)
|
||||||
@@ -56,7 +58,9 @@ async function cloneProject(req, res) {
|
|||||||
const {
|
const {
|
||||||
body: { targetProjectId },
|
body: { targetProjectId },
|
||||||
params: { project_id: sourceProjectId },
|
params: { project_id: sourceProjectId },
|
||||||
} = parseReq(req, schemas.cloneProject)
|
} = parseReq(req, schemas.cloneProject, {
|
||||||
|
fallbackSchema: schemas.cloneProjectFallbackSchema,
|
||||||
|
})
|
||||||
|
|
||||||
const incrResp = new IncrementalResponse({
|
const incrResp = new IncrementalResponse({
|
||||||
res,
|
res,
|
||||||
@@ -109,7 +113,9 @@ async function cloneProject(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getLatestContent(req, res, next) {
|
async function getLatestContent(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.getLatestContent)
|
const { params } = parseReq(req, schemas.getLatestContent, {
|
||||||
|
fallbackSchema: schemas.getLatestContentFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
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)
|
||||||
@@ -120,7 +126,9 @@ async function getLatestContent(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getContentAtVersion(req, res, next) {
|
async function getContentAtVersion(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.getContentAtVersion)
|
const { params } = parseReq(req, schemas.getContentAtVersion, {
|
||||||
|
fallbackSchema: schemas.getContentAtVersionFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const version = params.version
|
const version = params.version
|
||||||
const blobStore = new BlobStore(projectId)
|
const blobStore = new BlobStore(projectId)
|
||||||
@@ -130,7 +138,9 @@ async function getContentAtVersion(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getLatestHashedContent(req, res, next) {
|
async function getLatestHashedContent(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.getLatestHashedContent)
|
const { params } = parseReq(req, schemas.getLatestHashedContent, {
|
||||||
|
fallbackSchema: schemas.getLatestHashedContentFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
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)
|
||||||
@@ -142,7 +152,9 @@ async function getLatestHashedContent(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getLatestHistory(req, res, next) {
|
async function getLatestHistory(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.getLatestHistory)
|
const { params } = parseReq(req, schemas.getLatestHistory, {
|
||||||
|
fallbackSchema: schemas.getLatestHistoryFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
try {
|
try {
|
||||||
const chunk = await chunkStore.loadLatest(projectId)
|
const chunk = await chunkStore.loadLatest(projectId)
|
||||||
@@ -158,7 +170,9 @@ async function getLatestHistory(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getLatestHistoryRaw(req, res, next) {
|
async function getLatestHistoryRaw(req, res, next) {
|
||||||
const { params, query } = parseReq(req, schemas.getLatestHistoryRaw)
|
const { params, query } = parseReq(req, schemas.getLatestHistoryRaw, {
|
||||||
|
fallbackSchema: schemas.getLatestHistoryRawFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const readOnly = query.readOnly
|
const readOnly = query.readOnly
|
||||||
try {
|
try {
|
||||||
@@ -179,7 +193,9 @@ async function getLatestHistoryRaw(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getHistory(req, res, next) {
|
async function getHistory(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.getHistory)
|
const { params } = parseReq(req, schemas.getHistory, {
|
||||||
|
fallbackSchema: schemas.getHistoryFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const version = params.version
|
const version = params.version
|
||||||
try {
|
try {
|
||||||
@@ -196,7 +212,9 @@ async function getHistory(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getHistoryBefore(req, res, next) {
|
async function getHistoryBefore(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.getHistoryBefore)
|
const { params } = parseReq(req, schemas.getHistoryBefore, {
|
||||||
|
fallbackSchema: schemas.getHistoryBeforeFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const timestamp = params.timestamp
|
const timestamp = params.timestamp
|
||||||
try {
|
try {
|
||||||
@@ -216,7 +234,9 @@ 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 { params, query } = parseReq(req, schemas.getChanges)
|
const { params, query } = parseReq(req, schemas.getChanges, {
|
||||||
|
fallbackSchema: schemas.getChangesFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const sinceParam = query.since
|
const sinceParam = query.since
|
||||||
const since = sinceParam == null ? 0 : sinceParam
|
const since = sinceParam == null ? 0 : sinceParam
|
||||||
@@ -245,7 +265,9 @@ async function getChanges(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getLatestZip(req, res, next) {
|
async function getLatestZip(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.getLatestZip)
|
const { params } = parseReq(req, schemas.getLatestZip, {
|
||||||
|
fallbackSchema: schemas.getLatestZipFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const blobStore = new BlobStore(projectId)
|
const blobStore = new BlobStore(projectId)
|
||||||
|
|
||||||
@@ -268,7 +290,9 @@ async function getLatestZip(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getZip(req, res, next) {
|
async function getZip(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.getZip)
|
const { params } = parseReq(req, schemas.getZip, {
|
||||||
|
fallbackSchema: schemas.getZipFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const version = params.version
|
const version = params.version
|
||||||
const blobStore = new BlobStore(projectId)
|
const blobStore = new BlobStore(projectId)
|
||||||
@@ -301,7 +325,9 @@ async function streamZip(snapshot, blobStore, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createZip(req, res, next) {
|
async function createZip(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.createZip)
|
const { params } = parseReq(req, schemas.createZip, {
|
||||||
|
fallbackSchema: schemas.createZipFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const version = params.version
|
const version = params.version
|
||||||
try {
|
try {
|
||||||
@@ -322,7 +348,9 @@ async function createZip(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteProject(req, res, next) {
|
async function deleteProject(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.deleteProject)
|
const { params } = parseReq(req, schemas.deleteProject, {
|
||||||
|
fallbackSchema: schemas.deleteProjectFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const blobStore = new BlobStore(projectId)
|
const blobStore = new BlobStore(projectId)
|
||||||
|
|
||||||
@@ -335,7 +363,9 @@ async function deleteProject(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createProjectBlob(req, res, next) {
|
async function createProjectBlob(req, res, next) {
|
||||||
const { params } = parseReq(req, schemas.createProjectBlob)
|
const { params } = parseReq(req, schemas.createProjectBlob, {
|
||||||
|
fallbackSchema: schemas.createProjectBlobFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const expectedHash = params.hash
|
const expectedHash = params.hash
|
||||||
const maxUploadSize = parseInt(config.get('maxFileUploadSize'), 10)
|
const maxUploadSize = parseInt(config.get('maxFileUploadSize'), 10)
|
||||||
@@ -373,7 +403,9 @@ async function createProjectBlob(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function headProjectBlob(req, res) {
|
async function headProjectBlob(req, res) {
|
||||||
const { params } = parseReq(req, schemas.headProjectBlob)
|
const { params } = parseReq(req, schemas.headProjectBlob, {
|
||||||
|
fallbackSchema: schemas.headProjectBlobFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const hash = params.hash
|
const hash = params.hash
|
||||||
|
|
||||||
@@ -406,7 +438,9 @@ function _getRangeOpts(header) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getProjectBlob(req, res, next) {
|
async function getProjectBlob(req, res, next) {
|
||||||
const { params, headers } = parseReq(req, schemas.getProjectBlob)
|
const { params, headers } = parseReq(req, schemas.getProjectBlob, {
|
||||||
|
fallbackSchema: schemas.getProjectBlobFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const hash = params.hash
|
const hash = params.hash
|
||||||
const rangeHeader = headers.range || ''
|
const rangeHeader = headers.range || ''
|
||||||
@@ -474,7 +508,9 @@ async function getProjectBlob(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyProjectBlob(req, res, next) {
|
async function copyProjectBlob(req, res, next) {
|
||||||
const { params, query } = parseReq(req, schemas.copyProjectBlob)
|
const { params, query } = parseReq(req, schemas.copyProjectBlob, {
|
||||||
|
fallbackSchema: schemas.copyProjectBlobFallbackSchema,
|
||||||
|
})
|
||||||
const sourceProjectId = query.copyFrom
|
const sourceProjectId = query.copyFrom
|
||||||
const targetProjectId = params.project_id
|
const targetProjectId = params.project_id
|
||||||
const blobHash = params.hash
|
const blobHash = params.hash
|
||||||
@@ -544,7 +580,9 @@ function sumUpByteLength(blobs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getBlobStats(req, res) {
|
async function getBlobStats(req, res) {
|
||||||
const { params, body } = parseReq(req, schemas.getBlobStats)
|
const { params, body } = parseReq(req, schemas.getBlobStats, {
|
||||||
|
fallbackSchema: schemas.getBlobStatsFallbackSchema,
|
||||||
|
})
|
||||||
const projectId = params.project_id
|
const projectId = params.project_id
|
||||||
const blobHashes = body.blobHashes || []
|
const blobHashes = body.blobHashes || []
|
||||||
for (const hash of blobHashes) {
|
for (const hash of blobHashes) {
|
||||||
@@ -569,7 +607,9 @@ async function getBlobStats(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getProjectBlobsStats(req, res) {
|
async function getProjectBlobsStats(req, res) {
|
||||||
const { body } = parseReq(req, schemas.getProjectBlobsStats)
|
const { body } = parseReq(req, schemas.getProjectBlobsStats, {
|
||||||
|
fallbackSchema: schemas.getProjectBlobsStatsFallbackSchema,
|
||||||
|
})
|
||||||
const projectIds = body.projectIds
|
const projectIds = body.projectIds
|
||||||
const { blobs } = await getProjectBlobsBatch(
|
const { blobs } = await getProjectBlobsBatch(
|
||||||
projectIds.map(id => {
|
projectIds.map(id => {
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ const config = require('config')
|
|||||||
const HTTPStatus = require('http-status')
|
const HTTPStatus = require('http-status')
|
||||||
const jwt = require('jsonwebtoken')
|
const jwt = require('jsonwebtoken')
|
||||||
const tsscmp = require('tsscmp')
|
const tsscmp = require('tsscmp')
|
||||||
const { parseReq } = require('@overleaf/validation-tools')
|
const { parseReq, z } = require('@overleaf/validation-tools')
|
||||||
const schemas = require('../schema')
|
const schemas = require('../schema')
|
||||||
|
|
||||||
|
// middleware schema is non-strict: it validates only the field this
|
||||||
|
// middleware consumes; the route schema stays responsible for strictness
|
||||||
|
const tokenQuerySchema = z.object({
|
||||||
|
query: z.object({ token: z.string().optional() }),
|
||||||
|
})
|
||||||
|
|
||||||
function hasValidBasicAuthCredentials(req) {
|
function hasValidBasicAuthCredentials(req) {
|
||||||
const credentials = basicAuth(req)
|
const credentials = basicAuth(req)
|
||||||
if (!credentials) return false
|
if (!credentials) return false
|
||||||
@@ -68,8 +74,11 @@ function configureJWTAuth(mode = 'jwt') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let token
|
let token
|
||||||
if ((mode === 'either' || mode === 'token') && req.query.token) {
|
// Rollout-temporary: reading req.query.token directly on main; log-only
|
||||||
token = req.query.token
|
// while REQ_VALIDATION_MODE=log rolls this new call site out.
|
||||||
|
const { query } = parseReq(req, tokenQuerySchema, { logOnly: true })
|
||||||
|
if ((mode === 'either' || mode === 'token') && query.token) {
|
||||||
|
token = query.token
|
||||||
} else if (
|
} else if (
|
||||||
(mode === 'either' || mode === 'jwt') &&
|
(mode === 'either' || mode === 'jwt') &&
|
||||||
req.headers.authorization &&
|
req.headers.authorization &&
|
||||||
@@ -103,7 +112,9 @@ function configureJWTAuth(mode = 'jwt') {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const { params } = parseReq(req, schemas.projectId)
|
const { params } = parseReq(req, schemas.projectId, {
|
||||||
|
fallbackSchema: schemas.projectIdFallbackSchema,
|
||||||
|
})
|
||||||
if (decoded.project_id.toString() !== params.project_id.toString()) {
|
if (decoded.project_id.toString() !== params.project_id.toString()) {
|
||||||
const err = new Error('Wrong project_id')
|
const err = new Error('Wrong project_id')
|
||||||
err.statusCode = HTTPStatus.FORBIDDEN
|
err.statusCode = HTTPStatus.FORBIDDEN
|
||||||
|
|||||||
@@ -2,10 +2,21 @@
|
|||||||
|
|
||||||
const { z, zz } = 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 {
|
||||||
|
rawSnapshot,
|
||||||
|
rawChange,
|
||||||
|
rawBlobHash,
|
||||||
|
rawFileMetadata,
|
||||||
|
} = require('overleaf-editor-core/lib/schemas')
|
||||||
|
|
||||||
const hexHashPattern = new RegExp(Blob.HEX_HASH_RX_STRING)
|
const hexHashPattern = new RegExp(Blob.HEX_HASH_RX_STRING)
|
||||||
|
|
||||||
const fileSchema = z
|
// Rollout-temporary fallback building blocks (main's pre-refinement shapes
|
||||||
|
// for imported snapshots/changes, before they were validated against
|
||||||
|
// overleaf-editor-core's raw* schemas); delete alongside the
|
||||||
|
// importSnapshotFallbackSchema/importChangesFallbackSchema opts below when
|
||||||
|
// this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
const fallbackFileSchema = z
|
||||||
.object({
|
.object({
|
||||||
hash: z.string().optional(),
|
hash: z.string().optional(),
|
||||||
rangesHash: z.string().optional(),
|
rangesHash: z.string().optional(),
|
||||||
@@ -15,22 +26,22 @@ const fileSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
const v2DocVersionsSchema = z.object({
|
const fallbackV2DocVersionsSchema = z.object({
|
||||||
pathname: z.string().optional(),
|
pathname: z.string().optional(),
|
||||||
v: z.number().int().optional(),
|
v: z.number().int().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const snapshotSchema = z.object({
|
const fallbackSnapshotSchema = z.object({
|
||||||
files: z.record(z.string(), fileSchema),
|
files: z.record(z.string(), fallbackFileSchema),
|
||||||
projectVersion: z.string().optional(),
|
projectVersion: z.string().optional(),
|
||||||
v2DocVersions: z
|
v2DocVersions: z
|
||||||
.record(z.string(), v2DocVersionsSchema)
|
.record(z.string(), fallbackV2DocVersionsSchema)
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
timestamp: z.string().optional(),
|
timestamp: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const operationSchema = z
|
const fallbackOperationSchema = z
|
||||||
.object({
|
.object({
|
||||||
pathname: z.string().optional(),
|
pathname: z.string().optional(),
|
||||||
newPathname: z.string().optional(),
|
newPathname: z.string().optional(),
|
||||||
@@ -39,29 +50,27 @@ const operationSchema = z
|
|||||||
hash: z.string(),
|
hash: z.string(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
// TODO: Use rawEditOperation from overleaf-editor-core/lib/schemas.js
|
|
||||||
// instead
|
|
||||||
textOperation: z.array(z.any()).optional(),
|
textOperation: z.array(z.any()).optional(),
|
||||||
file: fileSchema.optional(),
|
file: fallbackFileSchema.optional(),
|
||||||
contentHash: z.string().optional(),
|
contentHash: z.string().optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
const originSchema = z
|
const fallbackOriginSchema = z
|
||||||
.object({
|
.object({
|
||||||
kind: z.string().optional(),
|
kind: z.string().optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
const changeSchema = z
|
const fallbackChangeSchema = z
|
||||||
.object({
|
.object({
|
||||||
timestamp: z.string(),
|
timestamp: z.string(),
|
||||||
operations: z.array(operationSchema),
|
operations: z.array(fallbackOperationSchema),
|
||||||
authors: z.array(z.number().int().nullable()).optional(),
|
authors: z.array(z.number().int().nullable()).optional(),
|
||||||
v2Authors: z.array(z.string().nullable()).optional(),
|
v2Authors: z.array(z.string().nullable()).optional(),
|
||||||
origin: originSchema.optional(),
|
origin: fallbackOriginSchema.optional(),
|
||||||
projectVersion: z.string().optional(),
|
projectVersion: z.string().optional(),
|
||||||
v2DocVersions: z.record(z.string(), v2DocVersionsSchema).optional(),
|
v2DocVersions: z.record(z.string(), fallbackV2DocVersionsSchema).optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
@@ -70,19 +79,38 @@ const setContentBodyBase = z.object({
|
|||||||
source: z.string(),
|
source: z.string(),
|
||||||
userId: zz.objectId().optional(),
|
userId: zz.objectId().optional(),
|
||||||
timestamp: zz.datetime(),
|
timestamp: zz.datetime(),
|
||||||
metadata: z.object({}).passthrough().optional(),
|
metadata: rawFileMetadata.optional(),
|
||||||
trackChanges: z.boolean().optional(),
|
trackChanges: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const schemas = {
|
const schemas = {
|
||||||
projectId: z.object({
|
projectId: z.object({
|
||||||
|
params: z
|
||||||
|
.object({
|
||||||
|
project_id: zz.projectHistoryId().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
projectIdFallbackSchema: z.object({
|
||||||
params: z
|
params: z
|
||||||
.object({
|
.object({
|
||||||
project_id: z.string().optional(),
|
project_id: z.string().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
initializeProject: z.object({
|
initializeProject: z.object({
|
||||||
|
body: z
|
||||||
|
.strictObject({
|
||||||
|
projectId: zz.projectHistoryId().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
initializeProjectFallbackSchema: z.object({
|
||||||
body: z
|
body: z
|
||||||
.object({
|
.object({
|
||||||
projectId: z.string().optional(),
|
projectId: z.string().optional(),
|
||||||
@@ -91,22 +119,48 @@ const schemas = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
cloneProject: z.object({
|
cloneProject: z.object({
|
||||||
|
body: z.strictObject({
|
||||||
|
targetProjectId: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
cloneProjectFallbackSchema: z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
targetProjectId: z.string(),
|
targetProjectId: z.string(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getProjectBlobsStats: z.object({
|
getProjectBlobsStats: z.object({
|
||||||
|
body: z.strictObject({
|
||||||
|
projectIds: z.array(zz.projectHistoryId()),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getProjectBlobsStatsFallbackSchema: z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
projectIds: z.array(z.string()),
|
projectIds: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getBlobStats: z.object({
|
getBlobStats: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
body: z.strictObject({
|
||||||
|
blobHashes: z.array(rawBlobHash),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getBlobStatsFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
@@ -116,13 +170,30 @@ const schemas = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
deleteProject: z.object({
|
deleteProject: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
deleteProjectFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
body: z.any().optional(),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getProjectBlob: z.object({
|
getProjectBlob: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
hash: z.string().regex(hexHashPattern),
|
||||||
|
}),
|
||||||
|
headers: z.object({
|
||||||
|
range: z.string().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getProjectBlobFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
hash: z.string().regex(hexHashPattern),
|
hash: z.string().regex(hexHashPattern),
|
||||||
@@ -133,6 +204,14 @@ const schemas = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
headProjectBlob: z.object({
|
headProjectBlob: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
hash: z.string().regex(hexHashPattern),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
headProjectBlobFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
hash: z.string().regex(hexHashPattern),
|
hash: z.string().regex(hexHashPattern),
|
||||||
@@ -140,14 +219,33 @@ const schemas = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
createProjectBlob: z.object({
|
createProjectBlob: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
hash: z.string().regex(hexHashPattern),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
createProjectBlobFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
hash: z.string().regex(hexHashPattern),
|
hash: z.string().regex(hexHashPattern),
|
||||||
}),
|
}),
|
||||||
body: z.any().optional(),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
copyProjectBlob: z.object({
|
copyProjectBlob: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
hash: z.string().regex(hexHashPattern),
|
||||||
|
}),
|
||||||
|
query: z.object({
|
||||||
|
copyFrom: zz.projectHistoryId(),
|
||||||
|
sizeLimit: z.coerce.number().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
copyProjectBlobFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
hash: z.string().regex(hexHashPattern),
|
hash: z.string().regex(hexHashPattern),
|
||||||
@@ -156,28 +254,58 @@ const schemas = {
|
|||||||
copyFrom: z.string(),
|
copyFrom: z.string(),
|
||||||
sizeLimit: z.coerce.number().optional(),
|
sizeLimit: z.coerce.number().optional(),
|
||||||
}),
|
}),
|
||||||
body: z.any().optional(),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getLatestContent: z.object({
|
getLatestContent: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getLatestContentFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getLatestHashedContent: z.object({
|
getLatestHashedContent: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getLatestHashedContentFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getLatestHistory: z.object({
|
getLatestHistory: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getLatestHistoryFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getLatestHistoryRaw: z.object({
|
getLatestHistoryRaw: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
query: z.object({
|
||||||
|
readOnly: z.coerce.boolean().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getLatestHistoryRawFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
@@ -188,11 +316,19 @@ const schemas = {
|
|||||||
|
|
||||||
getLatestPersistedHistory: z.object({
|
getLatestPersistedHistory: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: zz.projectHistoryId(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getHistory: z.object({
|
getHistory: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
version: z.coerce.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getHistoryFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
version: z.coerce.number(),
|
version: z.coerce.number(),
|
||||||
@@ -200,6 +336,14 @@ const schemas = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getContentAtVersion: z.object({
|
getContentAtVersion: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
version: z.coerce.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getContentAtVersionFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
version: z.coerce.number(),
|
version: z.coerce.number(),
|
||||||
@@ -207,6 +351,14 @@ const schemas = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getHistoryBefore: z.object({
|
getHistoryBefore: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
timestamp: zz.datetime(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getHistoryBeforeFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
timestamp: zz.datetime(),
|
timestamp: zz.datetime(),
|
||||||
@@ -214,12 +366,27 @@ const schemas = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getLatestZip: z.object({
|
getLatestZip: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getLatestZipFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getZip: z.object({
|
getZip: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
version: z.coerce.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getZipFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
version: z.coerce.number(),
|
version: z.coerce.number(),
|
||||||
@@ -227,14 +394,31 @@ const schemas = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
createZip: z.object({
|
createZip: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
version: z.coerce.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
createZipFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
version: z.coerce.number(),
|
version: z.coerce.number(),
|
||||||
}),
|
}),
|
||||||
body: z.any().optional(),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getChanges: z.object({
|
getChanges: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
query: z.object({
|
||||||
|
since: z.coerce.number().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
getChangesFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
@@ -244,13 +428,37 @@ const schemas = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
importSnapshot: z.object({
|
importSnapshot: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
body: rawSnapshot,
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main, before
|
||||||
|
// imported snapshots were validated against overleaf-editor-core's
|
||||||
|
// rawSnapshot); delete when this route's REQ_VALIDATION_MODE
|
||||||
|
// instrumentation is removed.
|
||||||
|
importSnapshotFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
body: snapshotSchema,
|
body: fallbackSnapshotSchema,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
importChanges: z.object({
|
importChanges: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
query: z.object({
|
||||||
|
end_version: z.coerce.number(),
|
||||||
|
return_snapshot: z.enum(['hashed', 'none']).optional(),
|
||||||
|
}),
|
||||||
|
body: z.array(rawChange),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main, before
|
||||||
|
// imported changes were validated against overleaf-editor-core's
|
||||||
|
// rawChange); delete when this route's REQ_VALIDATION_MODE
|
||||||
|
// instrumentation is removed.
|
||||||
|
importChangesFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
@@ -258,7 +466,7 @@ const schemas = {
|
|||||||
end_version: z.coerce.number(),
|
end_version: z.coerce.number(),
|
||||||
return_snapshot: z.enum(['hashed', 'none']).optional(),
|
return_snapshot: z.enum(['hashed', 'none']).optional(),
|
||||||
}),
|
}),
|
||||||
body: z.array(changeSchema),
|
body: z.array(fallbackChangeSchema),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setContent: z.object({
|
setContent: z.object({
|
||||||
@@ -287,17 +495,29 @@ const schemas = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
flushChanges: z.object({
|
flushChanges: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
flushChangesFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
body: z.any().optional(),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
expireProject: z.object({
|
expireProject: z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: zz.projectHistoryId(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
expireProjectFallbackSchema: z.object({
|
||||||
params: z.object({
|
params: z.object({
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
}),
|
}),
|
||||||
body: z.any().optional(),
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ const healthChecks = require('./api/controllers/health_checks')
|
|||||||
const { mongodb, loadGlobalBlobs } = require('./storage')
|
const { mongodb, loadGlobalBlobs } = require('./storage')
|
||||||
const projectsRoutes = require('./api/routes/projects')
|
const projectsRoutes = require('./api/routes/projects')
|
||||||
const projectImportRoutes = require('./api/routes/project_import')
|
const projectImportRoutes = require('./api/routes/project_import')
|
||||||
const { createHandleValidationError } = require('@overleaf/validation-tools')
|
const {
|
||||||
|
createHandleValidationError,
|
||||||
|
getRawReqInput,
|
||||||
|
} = require('@overleaf/validation-tools')
|
||||||
|
|
||||||
Events.setMaxListeners(20)
|
Events.setMaxListeners(20)
|
||||||
const app = express()
|
const app = express()
|
||||||
@@ -73,7 +76,10 @@ function setupErrorHandling() {
|
|||||||
app.use(handleValidationError)
|
app.use(handleValidationError)
|
||||||
|
|
||||||
app.use(function (err, req, res, next) {
|
app.use(function (err, req, res, next) {
|
||||||
const projectId = req.params?.project_id || req.body?.projectId
|
// terminal error handler must not throw while extracting log context
|
||||||
|
// (case 2: final error-handler logging)
|
||||||
|
const { params, body } = getRawReqInput(req)
|
||||||
|
const projectId = params?.project_id || body?.projectId
|
||||||
logger.error({ err, projectId }, err.message)
|
logger.error({ err, projectId }, err.message)
|
||||||
|
|
||||||
if (res.headersSent) {
|
if (res.headersSent) {
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ 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/middleware/security.js'
|
import { hasValidBasicAuthCredentials } from './api/middleware/security.js'
|
||||||
|
import {
|
||||||
|
handleValidationError,
|
||||||
|
parseReq,
|
||||||
|
z,
|
||||||
|
zz,
|
||||||
|
} from '@overleaf/validation-tools'
|
||||||
import {
|
import {
|
||||||
deleteProjectBackupCb,
|
deleteProjectBackupCb,
|
||||||
healthCheck,
|
healthCheck,
|
||||||
@@ -32,8 +38,21 @@ function basicAuth(req, res, next) {
|
|||||||
res.sendStatus(401)
|
res.sendStatus(401)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteProjectBackupSchema = z.object({
|
||||||
|
params: z.strictObject({
|
||||||
|
projectId: zz.objectId(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
app.delete('/project/:projectId/backup', basicAuth, (req, res, next) => {
|
app.delete('/project/:projectId/backup', basicAuth, (req, res, next) => {
|
||||||
deleteProjectBackupCb(req.params.projectId, err => {
|
// Rollout-temporary: main read req.params directly here; log-only while
|
||||||
|
// REQ_VALIDATION_MODE=log rolls this new call site out. No transforms in
|
||||||
|
// deleteProjectBackupSchema, so a raw passthrough on failure needs no
|
||||||
|
// fallback.
|
||||||
|
const { params } = parseReq(req, deleteProjectBackupSchema, {
|
||||||
|
logOnly: true,
|
||||||
|
})
|
||||||
|
deleteProjectBackupCb(params.projectId, err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return next(err)
|
return next(err)
|
||||||
}
|
}
|
||||||
@@ -52,6 +71,8 @@ app.get('/health_check', (req, res, next) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.use(handleValidationError)
|
||||||
|
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
req.logger.addFields({ err })
|
req.logger.addFields({ err })
|
||||||
if (err instanceof NotReadyToDelete) {
|
if (err instanceof NotReadyToDelete) {
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ import {
|
|||||||
} from './storage/lib/backupVerifier.mjs'
|
} from './storage/lib/backupVerifier.mjs'
|
||||||
import { mongodb } from './storage/index.js'
|
import { mongodb } from './storage/index.js'
|
||||||
import { expressify } from '@overleaf/promise-utils'
|
import { expressify } from '@overleaf/promise-utils'
|
||||||
|
import {
|
||||||
|
handleValidationError,
|
||||||
|
parseReq,
|
||||||
|
z,
|
||||||
|
zz,
|
||||||
|
} from '@overleaf/validation-tools'
|
||||||
import { Blob } from 'overleaf-editor-core'
|
import { Blob } from 'overleaf-editor-core'
|
||||||
|
import editorCoreSchemas from 'overleaf-editor-core/lib/schemas.js'
|
||||||
import { loadGlobalBlobs } from './storage/lib/blob_store/index.js'
|
import { loadGlobalBlobs } from './storage/lib/blob_store/index.js'
|
||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
import {
|
import {
|
||||||
@@ -33,10 +40,22 @@ Metrics.leaked_sockets.monitor(logger)
|
|||||||
Metrics.event_loop.monitor(logger)
|
Metrics.event_loop.monitor(logger)
|
||||||
Metrics.memory.monitor(logger)
|
Metrics.memory.monitor(logger)
|
||||||
|
|
||||||
|
const verifyBlobSchema = z.object({
|
||||||
|
params: z.strictObject({
|
||||||
|
historyId: zz.projectHistoryId(),
|
||||||
|
hash: editorCoreSchemas.rawBlobHash,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
'/history/:historyId/blob/:hash/verify',
|
'/history/:historyId/blob/:hash/verify',
|
||||||
expressify(async (req, res) => {
|
expressify(async (req, res) => {
|
||||||
const { historyId, hash } = req.params
|
// Rollout-temporary: main read req.params directly here; log-only while
|
||||||
|
// REQ_VALIDATION_MODE=log rolls this new call site out. No transforms in
|
||||||
|
// verifyBlobSchema, so a raw passthrough on failure needs no fallback.
|
||||||
|
const { historyId, hash } = parseReq(req, verifyBlobSchema, {
|
||||||
|
logOnly: true,
|
||||||
|
}).params
|
||||||
try {
|
try {
|
||||||
await verifyBlob(historyId, hash)
|
await verifyBlob(historyId, hash)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
@@ -65,6 +84,8 @@ app.get(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.use(handleValidationError)
|
||||||
|
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
req.logger.addFields({ err })
|
req.logger.addFields({ err })
|
||||||
req.logger.setLevel('error')
|
req.logger.setLevel('error')
|
||||||
|
|||||||
@@ -470,5 +470,22 @@ describe('backupVerifier', function () {
|
|||||||
const response = await verifyBlobHTTP(historyIdMongo, blobHashMongo)
|
const response = await verifyBlobHTTP(historyIdMongo, blobHashMongo)
|
||||||
expect(response.status).to.equal(200)
|
expect(response.status).to.equal(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects a historyId that is not a Mongo ObjectId or Postgres id', async function () {
|
||||||
|
const response = await verifyBlobHTTP(
|
||||||
|
'a%2F..%2F..%2F..%2F..%2F..%2Fetc',
|
||||||
|
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||||
|
)
|
||||||
|
expect(response.status).to.equal(404)
|
||||||
|
const body = await response.json()
|
||||||
|
expect(body.error).to.include('historyId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a hash that is not a 40-character hex blob hash', async function () {
|
||||||
|
const response = await verifyBlobHTTP(historyIdPostgres, 'not-a-hash')
|
||||||
|
expect(response.status).to.equal(404)
|
||||||
|
const body = await response.json()
|
||||||
|
expect(body.error).to.include('hash')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -432,4 +432,39 @@ describe('Project blobs API', function () {
|
|||||||
expect(response.status).to.equal(HTTPStatus.NO_CONTENT)
|
expect(response.status).to.equal(HTTPStatus.NO_CONTENT)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('validation', function () {
|
||||||
|
it('rejects a project_id that is not a Mongo ObjectId or Postgres id', async function () {
|
||||||
|
const badProjectId = 'a/../../../../../etc'
|
||||||
|
const badToken = testServer.createTokenForProject(badProjectId)
|
||||||
|
const response = await fetch(
|
||||||
|
testServer.url(
|
||||||
|
`/api/projects/${encodeURIComponent(badProjectId)}/blobs/${testFiles.HELLO_TXT_HASH}`
|
||||||
|
),
|
||||||
|
{ headers: { Authorization: `Bearer ${badToken}` } }
|
||||||
|
)
|
||||||
|
expect(response.status).to.equal(HTTPStatus.NOT_FOUND)
|
||||||
|
const body = await response.json()
|
||||||
|
expect(body.error).to.include('project_id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a copyFrom that is not a Mongo ObjectId or Postgres id', async function () {
|
||||||
|
const targetProjectId = '456'
|
||||||
|
const targetToken = testServer.createTokenForProject(targetProjectId)
|
||||||
|
const url = new URL(
|
||||||
|
testServer.url(
|
||||||
|
`/api/projects/${targetProjectId}/blobs/${testFiles.HELLO_TXT_HASH}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
url.searchParams.append('copyFrom', 'a/../../../../../etc')
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${targetToken}` },
|
||||||
|
})
|
||||||
|
expect(response.status).to.equal(HTTPStatus.UNPROCESSABLE_ENTITY)
|
||||||
|
const body = await response.json()
|
||||||
|
expect(body.error).to.include('copyFrom')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,4 +54,83 @@ describe('project import', function () {
|
|||||||
expect(importResponse.status).to.equal(HTTPStatus.CREATED)
|
expect(importResponse.status).to.equal(HTTPStatus.CREATED)
|
||||||
expect(importResponse.obj).to.deep.equal({ resyncNeeded: false })
|
expect(importResponse.obj).to.deep.equal({ resyncNeeded: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts an add-file operation with importedAt-only file metadata', async function () {
|
||||||
|
const basicAuthClient = testServer.basicAuthClient
|
||||||
|
const projectId = await testProjects.createEmptyProject()
|
||||||
|
|
||||||
|
// upload an empty file
|
||||||
|
const response = await fetch(
|
||||||
|
testServer.url(
|
||||||
|
`/api/projects/${projectId}/blobs/${File.EMPTY_FILE_HASH}`,
|
||||||
|
{ qs: { pathname: 'main.tex' } }
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: fs.createReadStream(testFiles.path('empty.tex')),
|
||||||
|
headers: {
|
||||||
|
Authorization: testServer.basicAuthHeader,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(response.ok).to.be.true
|
||||||
|
|
||||||
|
const testFile = File.fromHash(File.EMPTY_FILE_HASH, undefined, {
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
const testChange = new Change(
|
||||||
|
[Operation.addFile('main.tex', testFile)],
|
||||||
|
new Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
const importResponse =
|
||||||
|
await basicAuthClient.apis.ProjectImport.importChanges1({
|
||||||
|
project_id: projectId,
|
||||||
|
end_version: 0,
|
||||||
|
changes: [testChange.toRaw()],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(importResponse.status).to.equal(HTTPStatus.CREATED)
|
||||||
|
expect(importResponse.obj).to.deep.equal({ resyncNeeded: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts an add-file operation with main-flag and importedAt file metadata', async function () {
|
||||||
|
const basicAuthClient = testServer.basicAuthClient
|
||||||
|
const projectId = await testProjects.createEmptyProject()
|
||||||
|
|
||||||
|
// upload an empty file
|
||||||
|
const response = await fetch(
|
||||||
|
testServer.url(
|
||||||
|
`/api/projects/${projectId}/blobs/${File.EMPTY_FILE_HASH}`,
|
||||||
|
{ qs: { pathname: 'main.tex' } }
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: fs.createReadStream(testFiles.path('empty.tex')),
|
||||||
|
headers: {
|
||||||
|
Authorization: testServer.basicAuthHeader,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(response.ok).to.be.true
|
||||||
|
|
||||||
|
const testFile = File.fromHash(File.EMPTY_FILE_HASH, undefined, {
|
||||||
|
main: true,
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
const testChange = new Change(
|
||||||
|
[Operation.addFile('main.tex', testFile)],
|
||||||
|
new Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
const importResponse =
|
||||||
|
await basicAuthClient.apis.ProjectImport.importChanges1({
|
||||||
|
project_id: projectId,
|
||||||
|
end_version: 0,
|
||||||
|
changes: [testChange.toRaw()],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(importResponse.status).to.equal(HTTPStatus.CREATED)
|
||||||
|
expect(importResponse.obj).to.deep.equal({ resyncNeeded: false })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ describe('project controller', function () {
|
|||||||
blobHashes: ['non-existent-hash'],
|
blobHashes: ['non-existent-hash'],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
HTTPStatus.INTERNAL_SERVER_ERROR
|
HTTPStatus.UNPROCESSABLE_ENTITY
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import {
|
||||||
|
handleValidationError,
|
||||||
|
parseReq,
|
||||||
|
z,
|
||||||
|
zz,
|
||||||
|
} from '@overleaf/validation-tools'
|
||||||
|
|
||||||
|
const getFileSchema = z.object({
|
||||||
|
params: z.strictObject({
|
||||||
|
projectId: zz.objectId(),
|
||||||
|
fileId: zz.objectId(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
class MockFilestore {
|
class MockFilestore {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -10,11 +23,12 @@ class MockFilestore {
|
|||||||
this.app = express()
|
this.app = express()
|
||||||
|
|
||||||
this.app.get('/project/:projectId/file/:fileId', (req, res) => {
|
this.app.get('/project/:projectId/file/:fileId', (req, res) => {
|
||||||
const { projectId, fileId } = req.params
|
const { projectId, fileId } = parseReq(req, getFileSchema).params
|
||||||
const content = this.files[projectId]?.[fileId]
|
const content = this.files[projectId]?.[fileId]
|
||||||
if (!content) return res.status(404).end()
|
if (!content) return res.status(404).end()
|
||||||
res.status(200).end(content)
|
res.status(200).end(content)
|
||||||
})
|
})
|
||||||
|
this.app.use(handleValidationError)
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
|
|||||||
@@ -71,10 +71,6 @@ describe('Validation', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return 400 for a non-datetime created_at in the label body', async function () {
|
it('should return 400 for a non-datetime created_at in the label body', async function () {
|
||||||
// created_at previously accepted a bare string (e.g. an epoch-ms
|
|
||||||
// string), so an invalid value would silently become an Invalid Date
|
|
||||||
// downstream instead of being rejected at the schema boundary -- see
|
|
||||||
// zod-string-field-hardening.md's housekeeping note.
|
|
||||||
const projectId = new ObjectId().toString()
|
const projectId = new ObjectId().toString()
|
||||||
let err
|
let err
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user