first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import { captureException } from './error-reporter'
import { ErrorBoundary } from 'react-error-boundary'
function errorHandler(error, componentStack) {
captureException(error, {
extra: {
componentStack,
},
tags: {
handler: 'react-error-boundary',
},
})
}
function DefaultFallbackComponent() {
return <></>
}
function withErrorBoundary(WrappedComponent, FallbackComponent) {
function ErrorBoundaryWrapper(props) {
return (
<ErrorBoundary
FallbackComponent={FallbackComponent || DefaultFallbackComponent}
onError={errorHandler}
>
<WrappedComponent {...props} />
</ErrorBoundary>
)
}
ErrorBoundaryWrapper.propTypes = WrappedComponent.propTypes
ErrorBoundaryWrapper.displayName = `WithErrorBoundaryWrapper${
WrappedComponent.displayName || WrappedComponent.name || 'Component'
}`
return ErrorBoundaryWrapper
}
export default withErrorBoundary

View File

@@ -0,0 +1,128 @@
// Conditionally enable Sentry based on whether the DSN token is set
import getMeta from '../utils/meta'
import OError from '@overleaf/o-error'
import { debugConsole } from '@/utils/debugging'
const {
sentryAllowedOriginRegex,
sentryDsn,
sentryEnvironment,
sentryRelease,
} = getMeta('ol-ExposedSettings')
const reporterPromise = sentryDsn ? sentryReporter() : nullReporter()
function sentryReporter() {
return (
import(/* webpackMode: "eager" */ '@sentry/browser')
.then(Sentry => {
let eventCount = 0
Sentry.init({
dsn: sentryDsn,
environment: sentryEnvironment,
release: sentryRelease,
autoSessionTracking: false,
// Ignore errors unless they come from our origins
// Adapted from: https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
allowUrls: [new RegExp(sentryAllowedOriginRegex)],
ignoreErrors: [
// Ignore very noisy error
'SecurityError: Permission denied to access property "pathname" on cross-origin object',
// Ignore unhandled error that is "expected" - see https://github.com/overleaf/issues/issues/3321
/^Missing PDF/,
// Ignore "expected" error from aborted fetch - see https://github.com/overleaf/issues/issues/3321
/^AbortError/,
// Ignore spurious error from Ace internals - see https://github.com/overleaf/issues/issues/3321
'ResizeObserver loop limit exceeded',
'ResizeObserver loop completed with undelivered notifications.',
// Microsoft Outlook SafeLink crawler
// https://forum.sentry.io/t/unhandledrejection-non-error-promise-rejection-captured-with-value/14062
/Non-Error promise rejection captured with value: Object Not Found Matching Id/,
// Ignore CM6 error until upgraded
"Cannot read properties of undefined (reading 'length')",
// Ignore Angular digest iteration limit - see https://github.com/overleaf/internal/issues/15750
'10 $digest() iterations reached',
// Ignore a frequent unhandled promise rejection
/Non-Error promise rejection captured with keys: currentTarget, detail, isTrusted, target/,
/Non-Error promise rejection captured with keys: message, status/,
],
denyUrls: [
// Chrome extensions
/extensions\//i,
/^chrome:\/\//i,
],
beforeSend(event) {
// Limit number of events sent to Sentry to 100 events "per page load",
// (i.e. the cap will be reset if the page is reloaded). This prevent
// hitting their server-side event cap.
eventCount++
if (eventCount > 100) {
return null // Block the event from sending
}
// Do not send events related to third party code (extensions)
if (
(event.extra?.arguments as { type: string }[] | undefined)?.[0]
?.type === 'UNSTABLE_editor:extensions'
) {
return null // Block the event from sending
}
return event
},
})
Sentry.setUser({ id: getMeta('ol-user_id') })
const splitTestAssignments = getMeta('ol-splitTestVariants')
if (splitTestAssignments) {
for (const [name, value] of Object.entries(splitTestAssignments)) {
// Ensure Sentry tag name is within the 32-character limit
Sentry.setTag(`ol.${name}`.slice(0, 32), value.toString())
}
}
return Sentry
})
// If Sentry fails to load, use the null reporter instead
.catch(error => {
debugConsole.error(error)
return nullReporter()
})
)
}
function nullReporter() {
return Promise.resolve({
captureException: debugConsole.error,
captureMessage: debugConsole.error,
})
}
// https://develop.sentry.dev/sdk/data-model/event-payloads/contexts/
// https://docs.sentry.io/platforms/javascript/enriching-events/context/#passing-context-directly
type Options = {
tags?: Record<string, any>
extra?: Record<string, any>
}
export function captureException(err: Error, options?: Options) {
options = options || {}
const extra = Object.assign(OError.getFullInfo(err), options.extra || {})
const fullStack = OError.getFullStack(err)
if (err.stack !== fullStack) {
// Attach tracebacks from OError.tag() and OError.cause.
extra.fullStack = fullStack
}
reporterPromise.then(reporter =>
reporter.captureException(err, {
...options,
extra,
})
)
}

View File

@@ -0,0 +1,109 @@
import sessionStorage from './session-storage'
import getMeta from '@/utils/meta'
type Segmentation = Record<
string,
string | number | boolean | undefined | unknown | any // TODO: RecurlyError
>
const CACHE_KEY = 'mbEvents'
function alreadySent(key: string) {
const eventCache = sessionStorage.getItem(CACHE_KEY) || {}
return !!eventCache[key]
}
function markAsSent(key: string) {
const eventCache = sessionStorage.getItem(CACHE_KEY) || {}
eventCache[key] = true
sessionStorage.setItem(CACHE_KEY, eventCache)
}
export function send(
category: string,
action: string,
label?: string,
value?: string
) {
if (typeof window.ga === 'function') {
window.ga('send', 'event', category, action, label, value)
}
}
export function sendOnce(
category: string,
action: string,
label: string,
value: string
) {
if (alreadySent(category)) return
if (typeof window.ga !== 'function') return
window.ga('send', 'event', category, action, label, value)
markAsSent(category)
}
export function sendMB(key: string, segmentation: Segmentation = {}) {
if (!segmentation.page) {
segmentation.page = window.location.pathname
}
if (getMeta('ol-customerIoEnabled')) {
segmentation['customerio-integration'] = 'enabled'
}
sendBeacon(key, segmentation)
if (typeof window.gtag !== 'function') return
if (['paywall-click', 'paywall-prompt', 'plans-page-click'].includes(key)) {
window.gtag('event', key, segmentation)
}
}
export function sendMBOnce(key: string, segmentation: Segmentation = {}) {
if (alreadySent(key)) return
sendMB(key, segmentation)
markAsSent(key)
}
export function sendMBSampled(
key: string,
segmentation: Segmentation = {},
rate = 0.01
) {
if (Math.random() < rate) {
sendMB(key, segmentation)
}
}
const sentOncePerPageLoad = new Set()
export function sendMBOncePerPageLoad(
key: string,
segmentation: Segmentation = {}
) {
if (sentOncePerPageLoad.has(key)) return
sendMB(key, segmentation)
sentOncePerPageLoad.add(key)
}
// Use breakpoint @screen-xs-max from less:
// @screen-xs-max: (@screen-sm-min - 1);
// @screen-sm-min: @screen-sm;
// @screen-sm: 768px;
export const isSmallDevice = window.screen.width < 768
function sendBeacon(key: string, data: Segmentation) {
if (!navigator || !navigator.sendBeacon) return
if (!getMeta('ol-ExposedSettings').isOverleaf) return
data._csrf = getMeta('ol-csrfToken')
const blob = new Blob([JSON.stringify(data)], {
type: 'application/json; charset=UTF-8',
})
try {
navigator.sendBeacon(`/event/${key}`, blob)
} catch (error) {
// Ignored. There's a range of browser for which `navigator.sendBeacon` is available but
// will throw an error if it's called with an unacceptable mime-typed Blob as the data.
}
}

View File

@@ -0,0 +1,253 @@
// fetch wrapper to make simple JSON requests:
// - send the CSRF token in the request
// - set the JSON content-type in the request headers
// - throw errors on non-ok response
// - parse JSON response body, unless response is empty
import OError from '@overleaf/o-error'
import getMeta from '@/utils/meta'
type FetchPath = string
// Custom config types are merged with `fetch`s RequestInit type
type FetchConfig = {
swallowAbortError?: boolean
body?: Record<string, unknown>
} & Omit<RequestInit, 'body'>
export function getJSON<T = any>(path: FetchPath, options?: FetchConfig) {
return fetchJSON<T>(path, { ...options, method: 'GET' })
}
export function postJSON<T = any>(path: FetchPath, options?: FetchConfig) {
return fetchJSON<T>(path, { ...options, method: 'POST' })
}
export function putJSON<T = any>(path: FetchPath, options?: FetchConfig) {
return fetchJSON<T>(path, { ...options, method: 'PUT' })
}
export function deleteJSON<T = any>(path: FetchPath, options?: FetchConfig) {
return fetchJSON<T>(path, { ...options, method: 'DELETE' })
}
function getErrorMessageForStatusCode(statusCode?: number) {
if (!statusCode) {
return 'Unknown Error'
}
const statusCodes: { readonly [K: number]: string } = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
429: 'Too Many Requests',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
}
return statusCodes[statusCode] ?? `Unexpected Error: ${statusCode}`
}
export class FetchError extends OError {
public url: string
public options?: RequestInit
public response?: Response
public data?: any
constructor(
message: string,
url: string,
options?: RequestInit,
response?: Response,
data?: any
) {
// On HTTP2, the `statusText` property is not set,
// so this `message` will be undefined. We need to
// set a message based on the response `status`, so
// our error UI rendering will work
if (!message) {
message = getErrorMessageForStatusCode(response?.status)
}
super(message, { statusCode: response ? response.status : undefined })
this.url = url
this.options = options
this.response = response
this.data = data
}
getErrorMessageKey() {
return this.data?.message?.key as string | undefined
}
getUserFacingMessage() {
const statusCode = this.response?.status
const defaultMessage = getErrorMessageForStatusCode(statusCode)
const message = (this.data?.message?.text || this.data?.message) as
| string
| undefined
if (message && message !== defaultMessage) return message
const statusCodes: { readonly [K: number]: string } = {
400: 'Invalid Request. Please correct the data and try again.',
403: 'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.',
429: 'Too many attempts. Please wait for a while and try again.',
}
return statusCode && statusCodes[statusCode]
? statusCodes[statusCode]
: 'Something went wrong. Please try again.'
}
}
function fetchJSON<T>(
path: FetchPath,
{
body,
headers = {},
method = 'GET',
credentials = 'same-origin',
swallowAbortError = true,
...otherOptions
}: FetchConfig
) {
const options: RequestInit = {
...otherOptions,
headers: {
...headers,
'Content-Type': 'application/json',
'X-Csrf-Token': getMeta('ol-csrfToken'),
Accept: 'application/json',
},
credentials,
method,
}
if (body !== undefined) {
options.body = JSON.stringify(body)
}
// The returned Promise and the `.then(handleSuccess, handleError)` handlers are needed
// to avoid calling `finally` in a Promise chain (and thus updating the component's state)
// after a component has unmounted.
// `resolve` will be called when the request succeeds, `reject` will be called when the request fails,
// but nothing will be called if the request is cancelled via an AbortController.
return new Promise<T>((resolve, reject) => {
fetch(path, options).then(
response => {
return parseResponseBody(response).then(
data => {
if (response.ok) {
resolve(data)
} else {
// the response from the server was not 2xx
reject(
new FetchError(
response.statusText,
path,
options,
response,
data
)
)
}
},
error => {
// swallow the error if the fetch was cancelled (e.g. by cancelling an AbortController on component unmount)
if (swallowAbortError && error.name === 'AbortError') {
// the fetch request was aborted while reading/parsing the response body
return
}
// parsing the response body failed
reject(
new FetchError(
'There was an error parsing the response body',
path,
options,
response
).withCause(error)
)
}
)
},
error => {
// swallow the error if the fetch was cancelled (e.g. by cancelling an AbortController on component unmount)
if (swallowAbortError && error.name === 'AbortError') {
// the fetch request was aborted before a response was returned
return
}
// the fetch failed
reject(
new FetchError(
'There was an error fetching the JSON',
path,
options
).withCause(error)
)
}
)
})
}
async function parseResponseBody(response: Response) {
const contentType = response.headers.get('Content-Type')
if (!contentType) {
return {}
}
if (/application\/json/.test(contentType)) {
return response.json()
}
if (/text\/plain/.test(contentType)) {
const message = await response.text()
return { message }
}
if (/text\/html/.test(contentType)) {
const message = await response.text()
// only use HTML responses which don't start with `<`
if (!/^\s*</.test(message)) {
return { message }
}
}
// response body ignored as content-type is either not set (e.g. 204
// responses) or unsupported
return {}
}
export function getErrorMessageKey(error: Error | null) {
if (!error) {
return undefined
}
if (error instanceof FetchError) {
return error.getErrorMessageKey()
}
return error.message
}
export function getUserFacingMessage(error: Error | null) {
if (!error) {
return undefined
}
if (error instanceof FetchError) {
return error.getUserFacingMessage()
}
return error.message
}
export function isRateLimited(error?: Error | FetchError | any) {
if (error && error instanceof FetchError) {
return error.response?.status === 429
}
return false
}

View File

@@ -0,0 +1,15 @@
// tracking code from https://help.hotjar.com/hc/en-us/articles/115009336727-How-to-Install-Your-Hotjar-Tracking-Code
export const initializeHotjar = (hotjarId, hotjarVersion) =>
(function (h, o, t, j, a, r) {
h.hj =
h.hj ||
function () {
;(h.hj.q = h.hj.q || []).push(arguments)
}
h._hjSettings = { hjid: hotjarId, hjsv: hotjarVersion }
a = o.getElementsByTagName('head')[0]
r = o.createElement('script')
r.async = 1
r.src = t + h._hjSettings.hjid + j + h._hjSettings.hjsv
a.appendChild(r)
})(window, document, 'https://static.hotjar.com/c/hotjar-', '.js?sv=')

View File

@@ -0,0 +1,43 @@
import getMeta from '@/utils/meta'
import { debugConsole } from '@/utils/debugging'
import { initializeHotjar } from '@/infrastructure/hotjar-snippet'
const { hotjarId, hotjarVersion } = getMeta('ol-ExposedSettings')
const shouldLoadHotjar = getMeta('ol-shouldLoadHotjar')
let hotjarInitialized = false
if (hotjarId && hotjarVersion && shouldLoadHotjar) {
const loadHotjar = () => {
// consent needed
if (!document.cookie.split('; ').some(item => item === 'oa=1')) {
return
}
if (!/^\d+$/.test(hotjarId) || !/^\d+$/.test(hotjarVersion)) {
debugConsole.error('Invalid Hotjar id or version')
return
}
// avoid inserting twice
if (!hotjarInitialized) {
debugConsole.log('Loading Hotjar')
hotjarInitialized = true
initializeHotjar(hotjarId, hotjarVersion)
}
}
// load when idle, if supported
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(loadHotjar)
} else {
loadHotjar()
}
// listen for consent
window.addEventListener('cookie-consent', event => {
if ((event as CustomEvent<boolean>).detail) {
loadHotjar()
}
})
}

View File

@@ -0,0 +1,52 @@
/**
* localStorage can throw browser exceptions, for example if it is full We don't
* use localStorage for anything critical, so in that case just fail gracefully.
*/
import { debugConsole } from '@/utils/debugging'
/**
* Catch, log and otherwise ignore errors.
*
* @param {function} fn localStorage function to call
* @param {string?} key Key passed to the localStorage function (if any)
* @param {any?} value Value passed to the localStorage function (if any)
*/
const callSafe = function (
fn: (...args: any) => any,
key?: string,
value?: any
) {
try {
return fn(key, value)
} catch (e) {
debugConsole.error('localStorage exception', e)
return null
}
}
const getItem = function (key: string) {
const value = localStorage.getItem(key)
return value === null ? null : JSON.parse(value)
}
const setItem = function (key: string, value: any) {
localStorage.setItem(key, JSON.stringify(value))
}
const clear = function () {
localStorage.clear()
}
const removeItem = function (key: string) {
return localStorage.removeItem(key)
}
const customLocalStorage = {
getItem: (key: string) => callSafe(getItem, key),
setItem: (key: string, value: any) => callSafe(setItem, key, value),
clear: () => callSafe(clear),
removeItem: (key: string) => callSafe(removeItem, key),
}
export default customLocalStorage

View File

@@ -0,0 +1,201 @@
import pLimit from 'p-limit'
import { Change, Chunk, Snapshot } from 'overleaf-editor-core'
import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
const DOWNLOAD_BLOBS_CONCURRENCY = 10
/**
* Project snapshot container with on-demand refresh
*/
export class ProjectSnapshot {
private projectId: string
private snapshot: Snapshot
private version: number
private blobStore: SimpleBlobStore
private refreshPromise: Promise<void>
private initialized: boolean
private refreshing: boolean
private queued: boolean
constructor(projectId: string) {
this.projectId = projectId
this.snapshot = new Snapshot()
this.version = 0
this.refreshPromise = Promise.resolve()
this.initialized = false
this.refreshing = false
this.queued = false
this.blobStore = new SimpleBlobStore(this.projectId)
}
/**
* Request a refresh of the snapshot.
*
* When the returned promise resolves, the snapshot is guaranteed to have been
* updated at least to the version of the document that was current when the
* function was called.
*/
async refresh() {
if (this.queued) {
// There already is a queued refresh that will run after this call.
// Just wait for it to complete.
await this.refreshPromise
} else if (this.refreshing) {
// There is a refresh running, but no queued refresh. Queue a refresh
// after this one and make it the new promise to wait for.
this.refreshPromise = this.queueRefresh()
await this.refreshPromise
} else {
// There is no refresh running. Start one.
this.refreshPromise = this.startRefresh()
await this.refreshPromise
}
}
/**
* Get the list of paths to editable docs.
*/
getDocPaths(): string[] {
const allPaths = this.snapshot.getFilePathnames()
return allPaths.filter(path => this.snapshot.getFile(path)?.isEditable())
}
/**
* Get the doc content at the given path.
*/
getDocContents(path: string): string | null {
const file = this.snapshot.getFile(path)
if (file == null) {
return null
}
return file.getContent({ filterTrackedDeletes: true }) ?? null
}
/**
* Immediately start a refresh
*/
private async startRefresh() {
this.refreshing = true
try {
if (!this.initialized) {
await this.initialize()
} else {
await this.loadChanges()
}
} finally {
this.refreshing = false
}
}
/**
* Queue a refresh after the currently running refresh
*/
private async queueRefresh() {
this.queued = true
try {
await this.refreshPromise
} catch {
// Ignore errors
}
this.queued = false
await this.startRefresh()
}
/**
* Initialize the snapshot using the project's latest chunk.
*
* This is run on the first refresh.
*/
private async initialize() {
await flushHistory(this.projectId)
const chunk = await fetchLatestChunk(this.projectId)
this.snapshot = chunk.getSnapshot()
this.snapshot.applyAll(chunk.getChanges())
this.version = chunk.getEndVersion()
await this.loadDocs()
this.initialized = true
}
/**
* Apply changes since the last refresh.
*
* This is run on the second and subsequent refreshes
*/
private async loadChanges() {
await flushHistory(this.projectId)
const changes = await fetchLatestChanges(this.projectId, this.version)
this.snapshot.applyAll(changes)
this.version += changes.length
await this.loadDocs()
}
/**
* Load all editable docs in the snapshot.
*
* This is done by converting any lazy file data into an "eager" file data. If
* a doc is already loaded, the load is a no-op.
*/
private async loadDocs() {
const paths = this.getDocPaths()
const limit = pLimit(DOWNLOAD_BLOBS_CONCURRENCY)
await Promise.all(
paths.map(path =>
limit(async () => {
const file = this.snapshot.getFile(path)
await file?.load('eager', this.blobStore)
})
)
)
}
}
/**
* Blob store that fetches blobs from the history service
*/
class SimpleBlobStore {
private projectId: string
constructor(projectId: string) {
this.projectId = projectId
}
async getString(hash: string): Promise<string> {
return await fetchBlob(this.projectId, hash)
}
async getObject(hash: string) {
const blob = await this.getString(hash)
return JSON.parse(blob)
}
}
async function flushHistory(projectId: string) {
await postJSON(`/project/${projectId}/flush`)
}
async function fetchLatestChunk(projectId: string): Promise<Chunk> {
const response = await getJSON<{ chunk: RawChunk }>(
`/project/${projectId}/latest/history`
)
return Chunk.fromRaw(response.chunk)
}
async function fetchLatestChanges(
projectId: string,
version: number
): Promise<Change[]> {
const response = await getJSON<RawChange[]>(
`/project/${projectId}/changes?since=${version}`
)
return response.map(Change.fromRaw).filter(change => change != null)
}
async function fetchBlob(projectId: string, hash: string): Promise<string> {
const url = `/project/${projectId}/blob/${hash}`
const res = await fetch(url)
if (!res.ok) {
throw new FetchError('Failed to fetch blob', url, undefined, res)
}
return await res.text()
}

View File

@@ -0,0 +1,13 @@
/**
* run `fn` in series for all values, and resolve with an array of the results
*/
export const mapSeries = async <T = any, V = any>(
values: T[],
fn: (item: T) => Promise<V>
) => {
const output: V[] = []
for (const value of values) {
output.push(await fn(value))
}
return output
}

View File

@@ -0,0 +1,52 @@
/**
* sessionStorage can throw browser exceptions, for example if it is full.
* We don't use sessionStorage for anything critical, so in that case just fail gracefully.
*/
import { debugConsole } from '@/utils/debugging'
/**
* Catch, log and otherwise ignore errors.
*
* @param {function} fn sessionStorage function to call
* @param {string?} key Key passed to the sessionStorage function (if any)
* @param {any?} value Value passed to the sessionStorage function (if any)
*/
const callSafe = function (
fn: (...args: any) => any,
key?: string,
value?: any
) {
try {
return fn(key, value)
} catch (e) {
debugConsole.error('sessionStorage exception', e)
return null
}
}
const getItem = function (key: string) {
const value = sessionStorage.getItem(key)
return value === null ? null : JSON.parse(value)
}
const setItem = function (key: string, value: any) {
sessionStorage.setItem(key, JSON.stringify(value))
}
const clear = function () {
sessionStorage.clear()
}
const removeItem = function (key: string) {
return sessionStorage.removeItem(key)
}
const customSessionStorage = {
getItem: (key: string) => callSafe(getItem, key),
setItem: (key: string, value: any) => callSafe(setItem, key, value),
clear: () => callSafe(clear),
removeItem: (key: string) => callSafe(removeItem, key),
}
export default customSessionStorage