Files
overleaf-cep/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs
T

459 lines
14 KiB
JavaScript

/* eslint-disable
max-len,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import SessionManager from '../Authentication/SessionManager.mjs'
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import _ from 'lodash'
import AnalyticsManager from '../../../../app/src/Features/Analytics/AnalyticsManager.mjs'
import LinkedFilesHandler from './LinkedFilesHandler.mjs'
import LinkedFilesErrors from './LinkedFilesErrors.mjs'
import {
OutputFileFetchFailedError,
FileTooLargeError,
TooManyFilesError,
} from '../Errors/Errors.js'
import Modules from '../../infrastructure/Modules.mjs'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import { plainTextResponse } from '../../infrastructure/Response.mjs'
import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
import { expressify } from '@overleaf/promise-utils'
import ProjectOutputFileAgent from './ProjectOutputFileAgent.mjs'
import ProjectFileAgent from './ProjectFileAgent.mjs'
import UrlAgent from './UrlAgent.mjs'
const {
CompileFailedError,
UrlFetchFailedError,
InvalidUrlError,
AccessDeniedError,
BadEntityTypeError,
BadDataError,
ProjectNotFoundError,
V1ProjectNotFoundError,
SourceFileNotFoundError,
NotOriginalImporterError,
FeatureNotAvailableError,
RemoteServiceError,
FileCannotRefreshError,
} = LinkedFilesErrors
let LinkedFilesController
// Per-provider shape of the client-submitted `data` object. This mirrors
// overleaf-editor-core's `rawLinkedFileData` discriminated union (the
// *stored* linkedFileData shape), but is not identical to it: `provider`
// lives as a sibling of `data` here rather than inside it, `importedAt` is
// never client-submitted (set server-side after validation), and a couple
// of fields are intentionally looser than the stored shape because the
// agents apply defaults/coercions between validation and storage --
// `zotero`'s `format` defaults to 'bibtex' when absent (ZoteroAgent
// `_getFormat`), and `project_file`/`project_output_file`'s
// `v1_source_doc_id` may arrive as a number (existing callers do this; the
// agent's `_canCreate` check rejects v1 ids with its own 403, so this must
// not be intercepted by validation first).
const createLinkedFileSchema = z.object({
params: z.strictObject({
project_id: zz.objectId(),
}),
body: z.discriminatedUnion('provider', [
z.strictObject({
name: z.string(),
parent_folder_id: zz.objectId(),
provider: z.literal('url'),
data: z.strictObject({
url: z.string(),
}),
}),
z.strictObject({
name: z.string(),
parent_folder_id: zz.objectId(),
provider: z.literal('project_file'),
data: z.strictObject({
source_project_id: zz.objectId().optional(),
v1_source_doc_id: z.union([z.string(), z.number()]).optional(),
source_entity_path: z.string(),
}),
}),
z.strictObject({
name: z.string(),
parent_folder_id: zz.objectId(),
provider: z.literal('project_output_file'),
data: z.strictObject({
source_project_id: zz.objectId().optional(),
v1_source_doc_id: z.union([z.string(), z.number()]).optional(),
source_output_file_path: z.string(),
build_id: zz.buildId().optional(),
clsiServerId: z.string().optional(),
}),
}),
z.strictObject({
name: z.string(),
parent_folder_id: zz.objectId(),
provider: z.literal('mendeley'),
data: z.strictObject({
group_id: zz.routeSegment().optional(),
}),
}),
z.strictObject({
name: z.string(),
parent_folder_id: zz.objectId(),
provider: z.literal('zotero'),
data: z.strictObject({
format: z.enum(['bibtex', 'biblatex']).optional(),
group_id: zz.routeSegment().optional(),
}),
}),
z.strictObject({
name: z.string(),
parent_folder_id: zz.objectId(),
provider: z.literal('papers'),
data: z.strictObject({
group_id: zz.routeSegment().optional(),
}),
}),
]),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const createLinkedFileFallbackSchema = z.object({
params: z.object({
project_id: zz.objectId(),
}),
body: z.object({
name: z.string(),
provider: z.string(),
data: z.object({}).passthrough(),
parent_folder_id: zz.objectId(),
}),
})
const refreshLinkedFileSchema = z.object({
params: z.strictObject({
project_id: zz.objectId(),
file_id: zz.objectId(),
}),
body: z.strictObject({
clientId: z.string().optional(),
shouldReindexReferences: z.boolean().optional(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const refreshLinkedFileFallbackSchema = z.object({
params: z.object({
project_id: zz.objectId(),
file_id: zz.objectId(),
}),
body: z.object({
clientId: z.string().optional(),
shouldReindexReferences: z.boolean().optional(),
}),
})
// Keys whose values are safe to log as-is. Every other key is kept (so the shape of the data stays visible in the log) but its value is replaced.
const LINKED_FILE_DATA_ALLOW_LIST = [
'provider',
'importedAt',
'source_project_id',
'v1_source_doc_id',
'source_entity_path',
'source_output_file_path',
'format',
'group_id',
'clsiServerId',
]
function redactLinkedFileData(data) {
return Object.fromEntries(
Object.entries(data).map(([key, value]) => {
if (LINKED_FILE_DATA_ALLOW_LIST.includes(key)) return [key, value]
if (key === 'url') {
try {
return [key, new URL(value).origin + '/<redacted>']
} catch {
return [key, '<bad input>']
}
}
return [key, '<redacted>']
})
)
}
async function createLinkedFile(req, res, next) {
const { params, body } = parseReq(req, createLinkedFileSchema, {
fallbackSchema: createLinkedFileFallbackSchema,
})
const { project_id: projectId } = params
const { name, provider, data, parent_folder_id: parentFolderId } = body
const userId = SessionManager.getLoggedInUserId(req.session)
const Agent = await LinkedFilesController._getAgent(provider)
if (Agent == null) {
return res.sendStatus(400)
}
data.provider = provider
data.importedAt = new Date().toISOString()
const historySource = await SplitTestHandler.promises.featureFlagEnabled(
req,
res,
'linked-file-from-history',
{ includeReferer: true }
)
try {
const newFileId = await Agent.promises.createLinkedFile(
projectId,
data,
name,
parentFolderId,
userId,
historySource
)
if (name.endsWith('.bib')) {
AnalyticsManager.recordEventForSession(req.session, 'linked-bib-file', {
integration: provider,
})
}
return res.json({ new_file_id: newFileId })
} catch (err) {
return LinkedFilesController.handleError(
err,
{ projectId, userId, parentFolderId },
data,
req,
res,
next
)
}
}
async function refreshLinkedFile(req, res, next) {
const { params, body } = parseReq(req, refreshLinkedFileSchema, {
fallbackSchema: refreshLinkedFileFallbackSchema,
})
const { project_id: projectId, file_id: fileId } = params
const { clientId, shouldReindexReferences } = body
const userId = SessionManager.getLoggedInUserId(req.session)
const { file, parentFolder } = await LinkedFilesHandler.promises.getFileById(
projectId,
fileId
)
if (file == null) {
return res.sendStatus(404)
}
const { name } = file
const { linkedFileData } = file
if (
linkedFileData == null ||
(linkedFileData != null ? linkedFileData.provider : undefined) == null
) {
return res.sendStatus(409)
}
const { provider } = linkedFileData
const parentFolderId = parentFolder._id
const Agent = await LinkedFilesController._getAgent(provider)
if (Agent == null) {
return res.sendStatus(400)
}
linkedFileData.importedAt = new Date().toISOString()
const historySource = await SplitTestHandler.promises.featureFlagEnabled(
req,
res,
'linked-file-from-history',
{ includeReferer: true }
)
let newFileId
try {
newFileId = await Agent.promises.refreshLinkedFile(
projectId,
linkedFileData,
name,
parentFolderId,
userId,
historySource
)
} catch (err) {
return LinkedFilesController.handleError(
err,
{ projectId, userId, parentFolderId },
linkedFileData,
req,
res,
next
)
}
if (shouldReindexReferences) {
// Signal to clients that they should re-index references
EditorRealTimeController.emitToRoom(
projectId,
'references:keys:updated',
[],
true,
clientId
)
}
res.json({ new_file_id: newFileId })
}
export default LinkedFilesController = {
Agents: null,
async _cacheAgents() {
if (!LinkedFilesController.Agents) {
LinkedFilesController.Agents = _.extend(
{
url: UrlAgent,
project_file: ProjectFileAgent,
project_output_file: ProjectOutputFileAgent,
},
await Modules.linkedFileAgentsIncludes()
)
}
},
async _getAgent(provider) {
await LinkedFilesController._cacheAgents()
if (
!Object.prototype.hasOwnProperty.call(
LinkedFilesController.Agents,
provider
)
) {
return null
}
if (!Array.from(Settings.enabledLinkedFileTypes).includes(provider)) {
return null
}
return LinkedFilesController.Agents[provider]
},
createLinkedFile: expressify(createLinkedFile),
refreshLinkedFile: expressify(refreshLinkedFile),
handleError(error, info, linkedFileData, req, res, next) {
logger.warn(
{
error,
req,
...info,
linkedFileData: redactLinkedFileData(linkedFileData),
},
'failed to create/refresh linked file'
)
if (
error instanceof AccessDeniedError &&
error.message === 'Zotero account not linked'
) {
res.status(400)
plainTextResponse(res, 'Your account is not linked with Zotero')
} else if (error instanceof AccessDeniedError) {
res.status(403)
plainTextResponse(
res,
res.locals.translate(
'the_project_that_contains_this_file_is_not_shared_with_you'
)
)
} else if (error instanceof BadDataError) {
res.status(400)
plainTextResponse(res, 'The submitted data is not valid')
} else if (error instanceof BadEntityTypeError) {
res.status(400)
plainTextResponse(res, 'The file is the wrong type')
} else if (error instanceof SourceFileNotFoundError) {
res.status(404)
plainTextResponse(res, 'Source file not found')
} else if (error instanceof ProjectNotFoundError) {
res.status(404)
plainTextResponse(res, 'Project not found')
} else if (error instanceof V1ProjectNotFoundError) {
res.status(409)
plainTextResponse(
res,
'Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file'
)
} else if (error instanceof CompileFailedError) {
res.status(422)
plainTextResponse(
res,
res.locals.translate('generic_linked_file_compile_error')
)
} else if (error instanceof OutputFileFetchFailedError) {
res.status(404)
plainTextResponse(res, 'Could not get output file')
} else if (error instanceof UrlFetchFailedError) {
res.status(422)
if (error.cause instanceof FileTooLargeError) {
plainTextResponse(res, 'File too large')
} else {
plainTextResponse(
res,
`Your URL could not be reached (${error.info?.status || error.cause?.info?.status
} status code). Please check it and try again.`
)
}
} else if (error instanceof InvalidUrlError) {
res.status(422)
plainTextResponse(
res,
'Your URL is not valid. Please check it and try again.'
)
} else if (error instanceof NotOriginalImporterError) {
res.status(400)
plainTextResponse(
res,
'You are not the user who originally imported this file'
)
} else if (error instanceof FeatureNotAvailableError) {
res.status(400)
plainTextResponse(res, 'This feature is not enabled on your account')
} else if (error instanceof RemoteServiceError) {
if (error.info?.statusCode === 403) {
res.status(400).json({ relink: true })
} else if (error.info?.statusCode === 429) {
res.status(429).json({ message: 'rate_limited' })
} else {
res.status(502)
plainTextResponse(res, 'The remote service produced an error')
}
} else if (error instanceof FileCannotRefreshError) {
res.status(400)
plainTextResponse(res, 'This file cannot be refreshed')
} else if (error instanceof TooManyFilesError) {
res.status(400)
plainTextResponse(res, 'too many files')
} else if (/\bECONNREFUSED\b/.test(error.message)) {
res.status(500)
plainTextResponse(res, 'Importing references is not currently available')
} else if (error instanceof FileTooLargeError) {
res.status(422)
plainTextResponse(res, 'File too large')
} else {
next(error)
}
},
}