Compare commits

...

19 commits

Author SHA1 Message Date
yu-i-i
570ca81ec7 Fix template publishing to align with upstream project context changes 2025-07-22 19:07:41 +02:00
yu-i-i
726798d911 Fix missing Templates link on login page 2025-07-22 14:19:29 +02:00
yu-i-i
96051b211d Template Gallery: replace markdown-it with marked 2025-07-22 14:19:29 +02:00
yu-i-i
9b923b4332 Refactor Template Gallery; resolves #38 and #39
- Replace free-text license input with a select box
- Improve visual presentation of modals and enhance keyboard interaction
2025-07-22 14:19:29 +02:00
yu-i-i
a2a141bdd9 Make Template Gallery optional; rename environment variables 2025-07-22 14:19:28 +02:00
yu-i-i
901413e4f1 Add Template Gallery support 2025-07-22 14:19:28 +02:00
Jakob Ackermann
0546fb7233 [third-party-datastore] improve error handling (#26881)
* [third-party-datastore] use generic serializer for dropboxError

The `err` serializer will not pick up all the dropbox fields.

Co-authored-by: Thomas Mees <thomas.mees@overleaf.com>

* [third-party-datastore] handle user_suspended like insufficient_space

Unlink dropbox and display a notification (same key to clear later).

Co-authored-by: Thomas Mees <thomas.mees@overleaf.com>

* [third-party-datastore] skip retries when rejected with disallowed_name

Co-authored-by: Thomas Mees <thomas.mees@overleaf.com>

* [web] sort translations

* [web] update copy for dropbox_unlinked_because_suspended

Co-authored-by: Kamal Arkinstall <kamal.arkinstall@overleaf.com>

---------

Co-authored-by: Thomas Mees <thomas.mees@overleaf.com>
Co-authored-by: Kamal Arkinstall <kamal.arkinstall@overleaf.com>
GitOrigin-RevId: 8fbb9074d1d6eb879e904d79dd4b2a2c952ff902
2025-07-22 08:07:13 +00:00
Jakob Ackermann
b1880ba64d [monorepo] upgrade tough-cookie in request to latest version (#27249)
GitOrigin-RevId: 9096e05d2c337c3d3a9b4ca6efec8fd40c51a622
2025-07-22 08:07:08 +00:00
Jakob Ackermann
082121d3da [web] reject upload requests without a file path (#27156)
* [web] reject upload requests without a file path

* [web] update copy on error message and link to contact form

Co-authored-by: Kamal Arkinstall <kamal.arkinstall@overleaf.com>

* [web] update copy: move dot to the end

---------

Co-authored-by: Kamal Arkinstall <kamal.arkinstall@overleaf.com>
GitOrigin-RevId: ba1ee81a91b046540caeb2f3f3da0e305611b35f
2025-07-22 08:07:03 +00:00
Jakob Ackermann
81f0807fc6 [web] prepare filestore migration for Server Pro/CE (#27230)
* [web] prepare filestore migration for Server Pro/CE

* [history-v1] remove unused USER_FILES_BUCKET_NAME env var from script

* [server-ce] tests: write default docker-compose.override.yml on startup

* [server-ce] tests: extend access logging of host-admin for response

* [server-ce] tests: test text and binary file upload

* [server-ce] tests: add tests for filestore migration

* [web] simplify feature gate for filestore/project-history-blobs logic

Co-authored-by: Brian Gough <brian.gough@overleaf.com>

* [server-ce] test: fix flaky test helper

---------

Co-authored-by: Brian Gough <brian.gough@overleaf.com>
GitOrigin-RevId: f89bdab2749e2b7a49d609e2eac6bf621c727966
2025-07-22 08:06:58 +00:00
Jakob Ackermann
bf43d4f709 [history-v1] make back_fill_file_hash_fix_up compatible with Server Pro (#27280)
* [history-v1] move MockFilestore into shared place

Co-authored-by: Brian Gough <brian.gough@overleaf.com>

* [history-v1] make back_fill_file_hash_fix_up compatible with Server Pro

---------

Co-authored-by: Brian Gough <brian.gough@overleaf.com>
GitOrigin-RevId: 70ea57e1503031d9f14dcd60c4c110e746450587
2025-07-22 08:06:41 +00:00
David
ae3f63d37f Merge pull request #27209 from overleaf/dp-collaborator-colour
Adapt online user and chat user colors based on luminance

GitOrigin-RevId: 1b0c843147ee3dc585866bc491a7c7613cb00e70
2025-07-22 08:06:32 +00:00
Antoine Clausse
30b0cabbbc [web] Update tests to add emails with 6-digits flow (#27076)
* In tests, post to `/user/emails/secondary` (6-digits) instead of the deprecated `/user/emails` (link-token)

* Update `addEmailAndConfirm` so it calls the right endpoint

* Remove unnecessary `userId` from `confirmEmail` and `addEmailAndConfirm` args

* Use `updateUser` to add unconfirmed email to user

* Confirm, then unconfirm emails, in order to test on unconfirmed emails

* Lowercase emails in `unconfirmSecondaryEmail`, so they get matched correctly

* Update UserEmailsTests.mjs with 6-digits flow, fetch, no `npm:async`

GitOrigin-RevId: 71b9ed65daebea5f22272240559caab375515f0c
2025-07-22 08:06:23 +00:00
Tim Down
2f427ef0e0 Merge pull request #27229 from overleaf/td-group-pricing-select
Allow clicks on icon in group plans select lists to open the select

GitOrigin-RevId: d54b27851cb8b5541d71c48ff815d52cf99db16f
2025-07-22 08:06:10 +00:00
Tim Down
0778bab910 Merge pull request #27254 from overleaf/td-project-dashboard-cookie-banner
Implement React cookie banner on project dashboard

GitOrigin-RevId: 95d2778d7ce7cb3054a06b06486b815a3453a623
2025-07-22 08:06:05 +00:00
Domagoj Kriskovic
d5b5710d01 Add docModified hook in ds-mobile-app module (#27196)
* Add docModified hook in ds-mobile-app module

* use Object.entries when iterating over promises

* avoid project lookup

* update tests

GitOrigin-RevId: 88676746f56558a97ce31010b57f5eeb254fefef
2025-07-22 08:05:56 +00:00
Domagoj Kriskovic
868d562d96 Support password-fallbackPassword array in requireBasicAuth (#27237)
GitOrigin-RevId: 33b15a05996bfa0190041f347772867a9667e2ca
2025-07-22 08:05:51 +00:00
Andrew Rumble
5d79cf18c0 Define all initial roles
GitOrigin-RevId: ad613bad4d8a47e327281e90b5475e989a3ccec4
2025-07-22 08:05:42 +00:00
Christopher Hoskin
7ecee2e0aa Merge pull request #27255 from overleaf/revert-27252-revert-26843-csh-issue-26608-mongo8-dev-ci
Revert "Revert "Upgrade the dev environment and CI to mongo 8""

GitOrigin-RevId: 5074b012504e65240017f1fde9b0d8d04c7b8b61
2025-07-22 08:05:25 +00:00
147 changed files with 4400 additions and 601 deletions

View file

@ -123,14 +123,14 @@ services:
dockerfile: services/real-time/Dockerfile dockerfile: services/real-time/Dockerfile
env_file: env_file:
- dev.env - dev.env
redis: redis:
image: redis:5 image: redis:5
ports: ports:
- "127.0.0.1:6379:6379" # for debugging - "127.0.0.1:6379:6379" # for debugging
volumes: volumes:
- redis-data:/data - redis-data:/data
web: web:
build: build:
context: .. context: ..

31
package-lock.json generated
View file

@ -35581,6 +35581,7 @@
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
"integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
"deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"aws-sign2": "~0.7.0", "aws-sign2": "~0.7.0",
"aws4": "^1.8.0", "aws4": "^1.8.0",
@ -35638,15 +35639,15 @@
} }
}, },
"node_modules/request/node_modules/tough-cookie": { "node_modules/request/node_modules/tough-cookie": {
"version": "2.5.0", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"psl": "^1.1.28", "tldts": "^6.1.32"
"punycode": "^2.1.1"
}, },
"engines": { "engines": {
"node": ">=0.8" "node": ">=16"
} }
}, },
"node_modules/requestretry": { "node_modules/requestretry": {
@ -39612,6 +39613,24 @@
"tlds": "bin.js" "tlds": "bin.js"
} }
}, },
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"license": "MIT"
},
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.2.3", "version": "0.2.3",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz",

View file

@ -33,6 +33,9 @@
"path-to-regexp": "3.3.0", "path-to-regexp": "3.3.0",
"body-parser": "1.20.3", "body-parser": "1.20.3",
"multer": "2.0.1" "multer": "2.0.1"
},
"request@2.88.2": {
"tough-cookie": "5.1.2"
} }
}, },
"scripts": { "scripts": {

View file

@ -21,9 +21,11 @@ test-e2e-native:
test-e2e: test-e2e:
docker compose build host-admin docker compose build host-admin
docker compose up -d host-admin
docker compose up --no-log-prefix --exit-code-from=e2e e2e docker compose up --no-log-prefix --exit-code-from=e2e e2e
test-e2e-open: test-e2e-open:
docker compose up -d host-admin
docker compose up --no-log-prefix --exit-code-from=e2e-open e2e-open docker compose up --no-log-prefix --exit-code-from=e2e-open e2e-open
clean: clean:

View file

@ -35,7 +35,7 @@ services:
MAILTRAP_PASSWORD: 'password-for-mailtrap' MAILTRAP_PASSWORD: 'password-for-mailtrap'
mongo: mongo:
image: mongo:6.0 image: mongo:8.0.11
command: '--replSet overleaf' command: '--replSet overleaf'
volumes: volumes:
- ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -2,6 +2,7 @@ import {
createNewFile, createNewFile,
createProject, createProject,
openProjectById, openProjectById,
testNewFileUpload,
} from './helpers/project' } from './helpers/project'
import { isExcludedBySharding, startWith } from './helpers/config' import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login' import { ensureUserExists, login } from './helpers/login'
@ -119,24 +120,7 @@ describe('editor', () => {
cy.get('button').contains('New file').click({ force: true }) cy.get('button').contains('New file').click({ force: true })
}) })
it('can upload file', () => { testNewFileUpload()
const name = `${uuid()}.txt`
const content = `Test File Content ${name}`
cy.get('button').contains('Upload').click({ force: true })
cy.get('input[type=file]')
.first()
.selectFile(
{
contents: Cypress.Buffer.from(content),
fileName: name,
lastModified: Date.now(),
},
{ force: true }
)
// force: The file-tree pane is too narrow to display the full name.
cy.findByTestId('file-tree').findByText(name).click({ force: true })
cy.findByText(content)
})
it('should not display import from URL', () => { it('should not display import from URL', () => {
cy.findByText('From external URL').should('not.exist') cy.findByText('From external URL').should('not.exist')

View file

@ -0,0 +1,104 @@
import { ensureUserExists, login } from './helpers/login'
import {
createProject,
openProjectById,
prepareFileUploadTest,
} from './helpers/project'
import { isExcludedBySharding, startWith } from './helpers/config'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
import { v4 as uuid } from 'uuid'
import { purgeFilestoreData, runScript } from './helpers/hostAdminClient'
describe('filestore migration', function () {
if (isExcludedBySharding('CE_CUSTOM_3')) return
startWith({ withDataDir: true, resetData: true, vars: {} })
ensureUserExists({ email: 'user@example.com' })
let projectName: string
let projectId: string
let waitForCompileRateLimitCoolOff: (fn: () => void) => void
const previousBinaryFiles: (() => void)[] = []
beforeWithReRunOnTestRetry(function () {
projectName = `project-${uuid()}`
login('user@example.com')
createProject(projectName, { type: 'Example project' }).then(
id => (projectId = id)
)
let queueReset
;({ waitForCompileRateLimitCoolOff, queueReset } =
prepareWaitForNextCompileSlot())
queueReset()
previousBinaryFiles.push(prepareFileUploadTest(true))
})
beforeEach(() => {
login('user@example.com')
waitForCompileRateLimitCoolOff(() => {
openProjectById(projectId)
})
})
function checkFilesAreAccessible() {
it('can upload new binary file and read previous uploads', function () {
previousBinaryFiles.push(prepareFileUploadTest(true))
for (const check of previousBinaryFiles) {
check()
}
})
it('renders frog jpg', () => {
cy.findByTestId('file-tree').findByText('frog.jpg').click()
cy.get('[alt="frog.jpg"]')
.should('be.visible')
.and('have.prop', 'naturalWidth')
.should('be.greaterThan', 0)
})
}
describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL not set', function () {
startWith({ withDataDir: true, vars: {} })
checkFilesAreAccessible()
})
describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=0', function () {
startWith({
withDataDir: true,
vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '0' },
})
checkFilesAreAccessible()
describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=1', function () {
startWith({
withDataDir: true,
vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '1' },
})
checkFilesAreAccessible()
describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=2', function () {
startWith({
withDataDir: true,
vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '1' },
})
before(async function () {
await runScript({
cwd: 'services/history-v1',
script: 'storage/scripts/back_fill_file_hash.mjs',
})
})
startWith({
withDataDir: true,
vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '2' },
})
checkFilesAreAccessible()
describe('purge filestore data', function () {
before(async function () {
await purgeFilestoreData()
})
checkFilesAreAccessible()
})
})
})
})
})

View file

@ -9,6 +9,7 @@ export function isExcludedBySharding(
| 'CE_DEFAULT' | 'CE_DEFAULT'
| 'CE_CUSTOM_1' | 'CE_CUSTOM_1'
| 'CE_CUSTOM_2' | 'CE_CUSTOM_2'
| 'CE_CUSTOM_3'
| 'PRO_DEFAULT_1' | 'PRO_DEFAULT_1'
| 'PRO_DEFAULT_2' | 'PRO_DEFAULT_2'
| 'PRO_CUSTOM_1' | 'PRO_CUSTOM_1'

View file

@ -85,6 +85,12 @@ export async function getRedisKeys() {
return stdout.split('\n') return stdout.split('\n')
} }
export async function purgeFilestoreData() {
await fetchJSON(`${hostAdminURL}/data/user_files`, {
method: 'DELETE',
})
}
async function sleep(ms: number) { async function sleep(ms: number) {
return new Promise(resolve => { return new Promise(resolve => {
setTimeout(resolve, ms) setTimeout(resolve, ms)

View file

@ -216,3 +216,43 @@ export function createNewFile() {
return fileName return fileName
} }
export function prepareFileUploadTest(binary = false) {
const name = `${uuid()}.txt`
const content = `Test File Content ${name}${binary ? ' \x00' : ''}`
cy.get('button').contains('Upload').click({ force: true })
cy.get('input[type=file]')
.first()
.selectFile(
{
contents: Cypress.Buffer.from(content),
fileName: name,
lastModified: Date.now(),
},
{ force: true }
)
// wait for the upload to finish
cy.findByRole('treeitem', { name })
return function check() {
cy.findByRole('treeitem', { name }).click()
if (binary) {
cy.findByText(content).should('not.have.class', 'cm-line')
} else {
cy.findByText(content).should('have.class', 'cm-line')
}
}
}
export function testNewFileUpload() {
it('can upload text file', () => {
const check = prepareFileUploadTest(false)
check()
})
it('can upload binary file', () => {
const check = prepareFileUploadTest(true)
check()
})
}

View file

@ -29,6 +29,17 @@ const IMAGES = {
PRO: process.env.IMAGE_TAG_PRO.replace(/:.+/, ''), PRO: process.env.IMAGE_TAG_PRO.replace(/:.+/, ''),
} }
function defaultDockerComposeOverride() {
return {
services: {
sharelatex: {
environment: {},
},
'git-bridge': {},
},
}
}
let previousConfig = '' let previousConfig = ''
function readDockerComposeOverride() { function readDockerComposeOverride() {
@ -38,14 +49,7 @@ function readDockerComposeOverride() {
if (error.code !== 'ENOENT') { if (error.code !== 'ENOENT') {
throw error throw error
} }
return { return defaultDockerComposeOverride
services: {
sharelatex: {
environment: {},
},
'git-bridge': {},
},
}
} }
} }
@ -77,12 +81,21 @@ app.use(bodyParser.json())
app.use((req, res, next) => { app.use((req, res, next) => {
// Basic access logs // Basic access logs
console.log(req.method, req.url, req.body) console.log(req.method, req.url, req.body)
const json = res.json
res.json = body => {
console.log(req.method, req.url, req.body, '->', body)
json.call(res, body)
}
next()
})
app.use((req, res, next) => {
// Add CORS headers // Add CORS headers
const accessControlAllowOrigin = const accessControlAllowOrigin =
process.env.ACCESS_CONTROL_ALLOW_ORIGIN || 'http://sharelatex' process.env.ACCESS_CONTROL_ALLOW_ORIGIN || 'http://sharelatex'
res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin) res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
res.setHeader('Access-Control-Allow-Headers', 'Content-Type') res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
res.setHeader('Access-Control-Max-Age', '3600') res.setHeader('Access-Control-Max-Age', '3600')
res.setHeader('Access-Control-Allow-Methods', 'DELETE, GET, HEAD, POST, PUT')
next() next()
}) })
@ -133,6 +146,7 @@ const allowedVars = Joi.object(
'V1_HISTORY_URL', 'V1_HISTORY_URL',
'SANDBOXED_COMPILES', 'SANDBOXED_COMPILES',
'ALL_TEX_LIVE_DOCKER_IMAGE_NAMES', 'ALL_TEX_LIVE_DOCKER_IMAGE_NAMES',
'OVERLEAF_FILESTORE_MIGRATION_LEVEL',
'OVERLEAF_TEMPLATES_USER_ID', 'OVERLEAF_TEMPLATES_USER_ID',
'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS', 'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS',
'OVERLEAF_ALLOW_PUBLIC_ACCESS', 'OVERLEAF_ALLOW_PUBLIC_ACCESS',
@ -319,8 +333,19 @@ app.get('/redis/keys', (req, res) => {
) )
}) })
app.delete('/data/user_files', (req, res) => {
runDockerCompose(
'exec',
['sharelatex', 'rm', '-rf', '/var/lib/overleaf/data/user_files'],
(error, stdout, stderr) => {
res.json({ error, stdout, stderr })
}
)
})
app.use(handleValidationErrors()) app.use(handleValidationErrors())
purgeDataDir() purgeDataDir()
writeDockerComposeOverride(defaultDockerComposeOverride())
app.listen(80) app.listen(80)

View file

@ -42,7 +42,7 @@ services:
command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs .
user: root user: root
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -44,7 +44,7 @@ services:
command: npm run --silent test:acceptance command: npm run --silent test:acceptance
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -42,7 +42,7 @@ services:
command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs .
user: root user: root
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -44,7 +44,7 @@ services:
command: npm run --silent test:acceptance command: npm run --silent test:acceptance
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -47,7 +47,7 @@ services:
command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs .
user: root user: root
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -49,7 +49,7 @@ services:
command: npm run --silent test:acceptance command: npm run --silent test:acceptance
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -55,7 +55,7 @@ services:
retries: 20 retries: 20
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -57,7 +57,7 @@ services:
retries: 20 retries: 20
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -111,6 +111,11 @@ if (settings.filestore.stores.template_files) {
keyBuilder.templateFileKeyMiddleware, keyBuilder.templateFileKeyMiddleware,
fileController.insertFile fileController.insertFile
) )
app.delete(
'/template/:template_id/v/:version/:format',
keyBuilder.templateFileKeyMiddleware,
fileController.deleteFile
)
} }
app.get( app.get(

View file

@ -5,7 +5,7 @@ const { callbackify } = require('node:util')
const safeExec = require('./SafeExec').promises const safeExec = require('./SafeExec').promises
const { ConversionError } = require('./Errors') const { ConversionError } = require('./Errors')
const APPROVED_FORMATS = ['png'] const APPROVED_FORMATS = ['png', 'jpg']
const FOURTY_SECONDS = 40 * 1000 const FOURTY_SECONDS = 40 * 1000
const KILL_SIGNAL = 'SIGTERM' const KILL_SIGNAL = 'SIGTERM'
@ -34,16 +34,14 @@ async function convert(sourcePath, requestedFormat) {
} }
async function thumbnail(sourcePath) { async function thumbnail(sourcePath) {
const width = '260x' const width = '548x'
return await convert(sourcePath, 'png', [ return await _convert(sourcePath, 'jpg', [
'convert', 'convert',
'-flatten', '-flatten',
'-background', '-background',
'white', 'white',
'-density', '-density',
'300', '300',
'-define',
`pdf:fit-page=${width}`,
`${sourcePath}[0]`, `${sourcePath}[0]`,
'-resize', '-resize',
width, width,
@ -51,16 +49,14 @@ async function thumbnail(sourcePath) {
} }
async function preview(sourcePath) { async function preview(sourcePath) {
const width = '548x' const width = '794x'
return await convert(sourcePath, 'png', [ return await _convert(sourcePath, 'jpg', [
'convert', 'convert',
'-flatten', '-flatten',
'-background', '-background',
'white', 'white',
'-density', '-density',
'300', '300',
'-define',
`pdf:fit-page=${width}`,
`${sourcePath}[0]`, `${sourcePath}[0]`,
'-resize', '-resize',
width, width,

View file

@ -150,7 +150,9 @@ async function _getConvertedFileAndCache(bucket, key, convertedKey, opts) {
let convertedFsPath let convertedFsPath
try { try {
convertedFsPath = await _convertFile(bucket, key, opts) convertedFsPath = await _convertFile(bucket, key, opts)
await ImageOptimiser.promises.compressPng(convertedFsPath) if (convertedFsPath.toLowerCase().endsWith(".png")) {
await ImageOptimiser.promises.compressPng(convertedFsPath)
}
await PersistorManager.sendFile(bucket, convertedKey, convertedFsPath) await PersistorManager.sendFile(bucket, convertedKey, convertedFsPath)
} catch (err) { } catch (err) {
LocalFileWriter.deleteFile(convertedFsPath, () => {}) LocalFileWriter.deleteFile(convertedFsPath, () => {})

View file

@ -75,7 +75,7 @@ services:
retries: 20 retries: 20
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -83,7 +83,7 @@ services:
retries: 20 retries: 20
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -150,10 +150,6 @@ const CONCURRENT_BATCHES = parseInt(process.env.CONCURRENT_BATCHES || '2', 10)
const RETRIES = parseInt(process.env.RETRIES || '10', 10) const RETRIES = parseInt(process.env.RETRIES || '10', 10)
const RETRY_DELAY_MS = parseInt(process.env.RETRY_DELAY_MS || '100', 10) const RETRY_DELAY_MS = parseInt(process.env.RETRY_DELAY_MS || '100', 10)
const USER_FILES_BUCKET_NAME = process.env.USER_FILES_BUCKET_NAME || ''
if (!USER_FILES_BUCKET_NAME) {
throw new Error('env var USER_FILES_BUCKET_NAME is missing')
}
const RETRY_FILESTORE_404 = process.env.RETRY_FILESTORE_404 === 'true' const RETRY_FILESTORE_404 = process.env.RETRY_FILESTORE_404 === 'true'
const BUFFER_DIR = fs.mkdtempSync( const BUFFER_DIR = fs.mkdtempSync(
process.env.BUFFER_DIR_PREFIX || '/tmp/back_fill_file_hash-' process.env.BUFFER_DIR_PREFIX || '/tmp/back_fill_file_hash-'

View file

@ -9,15 +9,12 @@ import { Blob } from 'overleaf-editor-core'
import { import {
BlobStore, BlobStore,
getStringLengthOfFile, getStringLengthOfFile,
GLOBAL_BLOBS,
makeBlobForFile, makeBlobForFile,
} from '../lib/blob_store/index.js' } from '../lib/blob_store/index.js'
import { db } from '../lib/mongodb.js' import { db } from '../lib/mongodb.js'
import commandLineArgs from 'command-line-args' import commandLineArgs from 'command-line-args'
import readline from 'node:readline' import readline from 'node:readline'
import { _blobIsBackedUp, backupBlob } from '../lib/backupBlob.mjs'
import { NotFoundError } from '@overleaf/object-persistor/src/Errors.js' import { NotFoundError } from '@overleaf/object-persistor/src/Errors.js'
import filestorePersistor from '../lib/persistor.js'
import { setTimeout } from 'node:timers/promises' import { setTimeout } from 'node:timers/promises'
// Silence warning. // Silence warning.
@ -52,12 +49,11 @@ ObjectId.cacheHexString = true
*/ */
/** /**
* @return {{FIX_NOT_FOUND: boolean, FIX_HASH_MISMATCH: boolean, FIX_DELETE_PERMISSION: boolean, FIX_MISSING_HASH: boolean, LOGS: string}} * @return {{FIX_NOT_FOUND: boolean, FIX_HASH_MISMATCH: boolean, FIX_MISSING_HASH: boolean, LOGS: string}}
*/ */
function parseArgs() { function parseArgs() {
const args = commandLineArgs([ const args = commandLineArgs([
{ name: 'fixNotFound', type: String, defaultValue: 'true' }, { name: 'fixNotFound', type: String, defaultValue: 'true' },
{ name: 'fixDeletePermission', type: String, defaultValue: 'true' },
{ name: 'fixHashMismatch', type: String, defaultValue: 'true' }, { name: 'fixHashMismatch', type: String, defaultValue: 'true' },
{ name: 'fixMissingHash', type: String, defaultValue: 'true' }, { name: 'fixMissingHash', type: String, defaultValue: 'true' },
{ name: 'logs', type: String, defaultValue: '' }, { name: 'logs', type: String, defaultValue: '' },
@ -74,20 +70,13 @@ function parseArgs() {
} }
return { return {
FIX_HASH_MISMATCH: boolVal('fixNotFound'), FIX_HASH_MISMATCH: boolVal('fixNotFound'),
FIX_DELETE_PERMISSION: boolVal('fixDeletePermission'),
FIX_NOT_FOUND: boolVal('fixHashMismatch'), FIX_NOT_FOUND: boolVal('fixHashMismatch'),
FIX_MISSING_HASH: boolVal('fixMissingHash'), FIX_MISSING_HASH: boolVal('fixMissingHash'),
LOGS: args.logs, LOGS: args.logs,
} }
} }
const { const { FIX_HASH_MISMATCH, FIX_NOT_FOUND, FIX_MISSING_HASH, LOGS } = parseArgs()
FIX_HASH_MISMATCH,
FIX_DELETE_PERMISSION,
FIX_NOT_FOUND,
FIX_MISSING_HASH,
LOGS,
} = parseArgs()
if (!LOGS) { if (!LOGS) {
throw new Error('--logs parameter missing') throw new Error('--logs parameter missing')
} }
@ -105,6 +94,37 @@ const STREAM_HIGH_WATER_MARK = parseInt(
) )
const SLEEP_BEFORE_EXIT = parseInt(process.env.SLEEP_BEFORE_EXIT || '1000', 10) const SLEEP_BEFORE_EXIT = parseInt(process.env.SLEEP_BEFORE_EXIT || '1000', 10)
// Filestore endpoint location
const FILESTORE_HOST = process.env.FILESTORE_HOST || '127.0.0.1'
const FILESTORE_PORT = process.env.FILESTORE_PORT || '3009'
async function fetchFromFilestore(projectId, fileId) {
const url = `http://${FILESTORE_HOST}:${FILESTORE_PORT}/project/${projectId}/file/${fileId}`
const response = await fetch(url)
if (!response.ok) {
if (response.status === 404) {
throw new NotFoundError('file not found in filestore', {
status: response.status,
})
}
const body = await response.text()
throw new OError('fetchFromFilestore failed', {
projectId,
fileId,
status: response.status,
body,
})
}
if (!response.body) {
throw new OError('fetchFromFilestore response has no body', {
projectId,
fileId,
status: response.status,
})
}
return response.body
}
/** @type {ProjectsCollection} */ /** @type {ProjectsCollection} */
const projectsCollection = db.collection('projects') const projectsCollection = db.collection('projects')
/** @type {DeletedProjectsCollection} */ /** @type {DeletedProjectsCollection} */
@ -302,19 +322,16 @@ async function setHashInMongo(projectId, fileId, hash) {
* @return {Promise<void>} * @return {Promise<void>}
*/ */
async function importRestoredFilestoreFile(projectId, fileId, historyId) { async function importRestoredFilestoreFile(projectId, fileId, historyId) {
const filestoreKey = `${projectId}/${fileId}`
const path = `${BUFFER_DIR}/${projectId}_${fileId}` const path = `${BUFFER_DIR}/${projectId}_${fileId}`
try { try {
let s let s
try { try {
s = await filestorePersistor.getObjectStream( s = await fetchFromFilestore(projectId, fileId)
USER_FILES_BUCKET_NAME,
filestoreKey
)
} catch (err) { } catch (err) {
if (err instanceof NotFoundError) { if (err instanceof NotFoundError) {
throw new OError('missing blob, need to restore filestore file', { throw new OError('missing blob, need to restore filestore file', {
filestoreKey, projectId,
fileId,
}) })
} }
throw err throw err
@ -325,7 +342,6 @@ async function importRestoredFilestoreFile(projectId, fileId, historyId) {
) )
const blobStore = new BlobStore(historyId) const blobStore = new BlobStore(historyId)
const blob = await blobStore.putFile(path) const blob = await blobStore.putFile(path)
await backupBlob(historyId, blob, path)
await setHashInMongo(projectId, fileId, blob.getHash()) await setHashInMongo(projectId, fileId, blob.getHash())
} finally { } finally {
await fs.promises.rm(path, { force: true }) await fs.promises.rm(path, { force: true })
@ -339,13 +355,9 @@ async function importRestoredFilestoreFile(projectId, fileId, historyId) {
* @return {Promise<Blob>} * @return {Promise<Blob>}
*/ */
async function bufferFilestoreFileToDisk(projectId, fileId, path) { async function bufferFilestoreFileToDisk(projectId, fileId, path) {
const filestoreKey = `${projectId}/${fileId}`
try { try {
await Stream.promises.pipeline( await Stream.promises.pipeline(
await filestorePersistor.getObjectStream( await fetchFromFilestore(projectId, fileId),
USER_FILES_BUCKET_NAME,
filestoreKey
),
fs.createWriteStream(path, { highWaterMark: STREAM_HIGH_WATER_MARK }) fs.createWriteStream(path, { highWaterMark: STREAM_HIGH_WATER_MARK })
) )
const blob = await makeBlobForFile(path) const blob = await makeBlobForFile(path)
@ -356,7 +368,8 @@ async function bufferFilestoreFileToDisk(projectId, fileId, path) {
} catch (err) { } catch (err) {
if (err instanceof NotFoundError) { if (err instanceof NotFoundError) {
throw new OError('missing blob, need to restore filestore file', { throw new OError('missing blob, need to restore filestore file', {
filestoreKey, projectId,
fileId,
}) })
} }
throw err throw err
@ -389,7 +402,7 @@ async function uploadFilestoreFile(projectId, fileId) {
const blob = await bufferFilestoreFileToDisk(projectId, fileId, path) const blob = await bufferFilestoreFileToDisk(projectId, fileId, path)
const hash = blob.getHash() const hash = blob.getHash()
try { try {
await ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) await ensureBlobExistsForFile(projectId, fileId, hash)
} catch (err) { } catch (err) {
if (!(err instanceof Blob.NotFoundError)) throw err if (!(err instanceof Blob.NotFoundError)) throw err
@ -397,7 +410,7 @@ async function uploadFilestoreFile(projectId, fileId) {
const historyId = project.overleaf.history.id.toString() const historyId = project.overleaf.history.id.toString()
const blobStore = new BlobStore(historyId) const blobStore = new BlobStore(historyId)
await blobStore.putBlob(path, blob) await blobStore.putBlob(path, blob)
await ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) await ensureBlobExistsForFile(projectId, fileId, hash)
} }
} finally { } finally {
await fs.promises.rm(path, { force: true }) await fs.promises.rm(path, { force: true })
@ -426,11 +439,7 @@ async function fixHashMismatch(line) {
await importRestoredFilestoreFile(projectId, fileId, historyId) await importRestoredFilestoreFile(projectId, fileId, historyId)
return true return true
} }
return await ensureBlobExistsForFileAndUploadToAWS( return await ensureBlobExistsForFile(projectId, fileId, computedHash)
projectId,
fileId,
computedHash
)
} }
/** /**
@ -444,30 +453,19 @@ async function hashAlreadyUpdatedInFileTree(projectId, fileId, hash) {
return fileRef.hash === hash return fileRef.hash === hash
} }
/**
* @param {string} projectId
* @param {string} hash
* @return {Promise<boolean>}
*/
async function needsBackingUpToAWS(projectId, hash) {
if (GLOBAL_BLOBS.has(hash)) return false
return !(await _blobIsBackedUp(projectId, hash))
}
/** /**
* @param {string} projectId * @param {string} projectId
* @param {string} fileId * @param {string} fileId
* @param {string} hash * @param {string} hash
* @return {Promise<boolean>} * @return {Promise<boolean>}
*/ */
async function ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) { async function ensureBlobExistsForFile(projectId, fileId, hash) {
const { project } = await getProject(projectId) const { project } = await getProject(projectId)
const historyId = project.overleaf.history.id.toString() const historyId = project.overleaf.history.id.toString()
const blobStore = new BlobStore(historyId) const blobStore = new BlobStore(historyId)
if ( if (
(await hashAlreadyUpdatedInFileTree(projectId, fileId, hash)) && (await hashAlreadyUpdatedInFileTree(projectId, fileId, hash)) &&
(await blobStore.getBlob(hash)) && (await blobStore.getBlob(hash))
!(await needsBackingUpToAWS(projectId, hash))
) { ) {
return false // already processed return false // already processed
} }
@ -488,7 +486,7 @@ async function ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) {
) )
if (writtenBlob.getHash() !== hash) { if (writtenBlob.getHash() !== hash) {
// Double check download, better safe than sorry. // Double check download, better safe than sorry.
throw new OError('blob corrupted', { writtenBlob }) throw new OError('blob corrupted', { writtenBlob, hash })
} }
let blob = await blobStore.getBlob(hash) let blob = await blobStore.getBlob(hash)
@ -497,7 +495,6 @@ async function ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) {
// HACK: Skip upload to GCS and finalize putBlob operation directly. // HACK: Skip upload to GCS and finalize putBlob operation directly.
await blobStore.backend.insertBlob(historyId, writtenBlob) await blobStore.backend.insertBlob(historyId, writtenBlob)
} }
await backupBlob(historyId, writtenBlob, path)
} finally { } finally {
await fs.promises.rm(path, { force: true }) await fs.promises.rm(path, { force: true })
} }
@ -505,16 +502,6 @@ async function ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) {
return true return true
} }
/**
* @param {string} line
* @return {Promise<boolean>}
*/
async function fixDeletePermission(line) {
let { projectId, fileId, hash } = JSON.parse(line)
if (!hash) hash = await computeFilestoreFileHash(projectId, fileId)
return await ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash)
}
/** /**
* @param {string} line * @param {string} line
* @return {Promise<boolean>} * @return {Promise<boolean>}
@ -526,7 +513,7 @@ async function fixMissingHash(line) {
} = await findFile(projectId, fileId) } = await findFile(projectId, fileId)
if (hash) { if (hash) {
// processed, double check // processed, double check
return await ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) return await ensureBlobExistsForFile(projectId, fileId, hash)
} }
await uploadFilestoreFile(projectId, fileId) await uploadFilestoreFile(projectId, fileId)
return true return true
@ -543,11 +530,6 @@ const CASES = {
flag: FIX_HASH_MISMATCH, flag: FIX_HASH_MISMATCH,
action: fixHashMismatch, action: fixHashMismatch,
}, },
'delete permission': {
match: 'storage.objects.delete',
flag: FIX_DELETE_PERMISSION,
action: fixDeletePermission,
},
'missing file hash': { 'missing file hash': {
match: '"bad file hash"', match: '"bad file hash"',
flag: FIX_MISSING_HASH, flag: FIX_MISSING_HASH,

View file

@ -20,7 +20,7 @@ import {
makeProjectKey, makeProjectKey,
} from '../../../../storage/lib/blob_store/index.js' } from '../../../../storage/lib/blob_store/index.js'
import express from 'express' import { mockFilestore } from './support/MockFilestore.mjs'
chai.use(chaiExclude) chai.use(chaiExclude)
const TIMEOUT = 20 * 1_000 const TIMEOUT = 20 * 1_000
@ -28,59 +28,6 @@ const TIMEOUT = 20 * 1_000
const projectsCollection = db.collection('projects') const projectsCollection = db.collection('projects')
const deletedProjectsCollection = db.collection('deletedProjects') const deletedProjectsCollection = db.collection('deletedProjects')
class MockFilestore {
constructor() {
this.host = process.env.FILESTORE_HOST || '127.0.0.1'
this.port = process.env.FILESTORE_PORT || 3009
// create a server listening on this.host and this.port
this.files = {}
this.app = express()
this.app.get('/project/:projectId/file/:fileId', (req, res) => {
const { projectId, fileId } = req.params
const content = this.files[projectId]?.[fileId]
if (!content) return res.status(404).end()
res.status(200).end(content)
})
}
start() {
// reset stored files
this.files = {}
// start the server
if (this.serverPromise) {
return this.serverPromise
} else {
this.serverPromise = new Promise((resolve, reject) => {
this.server = this.app.listen(this.port, this.host, err => {
if (err) return reject(err)
resolve()
})
})
return this.serverPromise
}
}
addFile(projectId, fileId, fileContent) {
if (!this.files[projectId]) {
this.files[projectId] = {}
}
this.files[projectId][fileId] = fileContent
}
deleteObject(projectId, fileId) {
if (this.files[projectId]) {
delete this.files[projectId][fileId]
if (Object.keys(this.files[projectId]).length === 0) {
delete this.files[projectId]
}
}
}
}
const mockFilestore = new MockFilestore()
/** /**
* @param {ObjectId} objectId * @param {ObjectId} objectId
* @return {string} * @return {string}

View file

@ -1,48 +1,24 @@
import fs from 'node:fs' import fs from 'node:fs'
import Crypto from 'node:crypto' import Crypto from 'node:crypto'
import Stream from 'node:stream'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import { Binary, ObjectId } from 'mongodb' import { Binary, ObjectId } from 'mongodb'
import { Blob } from 'overleaf-editor-core' import { Blob } from 'overleaf-editor-core'
import { backedUpBlobs, blobs, db } from '../../../../storage/lib/mongodb.js' import { db } from '../../../../storage/lib/mongodb.js'
import cleanup from './support/cleanup.js' import cleanup from './support/cleanup.js'
import testProjects from '../api/support/test_projects.js' import testProjects from '../api/support/test_projects.js'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import chai, { expect } from 'chai' import chai, { expect } from 'chai'
import chaiExclude from 'chai-exclude' import chaiExclude from 'chai-exclude'
import config from 'config' import { BlobStore } from '../../../../storage/lib/blob_store/index.js'
import { WritableBuffer } from '@overleaf/stream-utils' import { mockFilestore } from './support/MockFilestore.mjs'
import {
backupPersistor,
projectBlobsBucket,
} from '../../../../storage/lib/backupPersistor.mjs'
import projectKey from '../../../../storage/lib/project_key.js'
import {
BlobStore,
makeProjectKey,
} from '../../../../storage/lib/blob_store/index.js'
import ObjectPersistor from '@overleaf/object-persistor'
chai.use(chaiExclude) chai.use(chaiExclude)
const TIMEOUT = 20 * 1_000 const TIMEOUT = 20 * 1_000
const { deksBucket } = config.get('backupStore')
const { tieringStorageClass } = config.get('backupPersistor')
const projectsCollection = db.collection('projects') const projectsCollection = db.collection('projects')
const deletedProjectsCollection = db.collection('deletedProjects') const deletedProjectsCollection = db.collection('deletedProjects')
const FILESTORE_PERSISTOR = ObjectPersistor({
backend: 'gcs',
gcs: {
endpoint: {
apiEndpoint: process.env.GCS_API_ENDPOINT,
projectId: process.env.GCS_PROJECT_ID,
},
},
})
/** /**
* @param {ObjectId} objectId * @param {ObjectId} objectId
* @return {string} * @return {string}
@ -70,17 +46,6 @@ function binaryForGitBlobHash(gitBlobHash) {
return new Binary(Buffer.from(gitBlobHash, 'hex')) return new Binary(Buffer.from(gitBlobHash, 'hex'))
} }
async function listS3Bucket(bucket, wantStorageClass) {
const client = backupPersistor._getClientForBucket(bucket)
const response = await client.listObjectsV2({ Bucket: bucket }).promise()
for (const object of response.Contents || []) {
expect(object).to.have.property('StorageClass', wantStorageClass)
}
return (response.Contents || []).map(item => item.Key || '')
}
function objectIdFromTime(timestamp) { function objectIdFromTime(timestamp) {
return ObjectId.createFromTime(new Date(timestamp).getTime() / 1000) return ObjectId.createFromTime(new Date(timestamp).getTime() / 1000)
} }
@ -97,7 +62,6 @@ describe('back_fill_file_hash_fix_up script', function () {
const historyIdDeleted0 = projectIdDeleted0.toString() const historyIdDeleted0 = projectIdDeleted0.toString()
const fileIdWithDifferentHashFound = objectIdFromTime('2017-02-01T00:00:00Z') const fileIdWithDifferentHashFound = objectIdFromTime('2017-02-01T00:00:00Z')
const fileIdInGoodState = objectIdFromTime('2017-02-01T00:01:00Z') const fileIdInGoodState = objectIdFromTime('2017-02-01T00:01:00Z')
const fileIdBlobExistsInGCS0 = objectIdFromTime('2017-02-01T00:02:00Z')
const fileIdWithDifferentHashNotFound0 = objectIdFromTime( const fileIdWithDifferentHashNotFound0 = objectIdFromTime(
'2017-02-01T00:03:00Z' '2017-02-01T00:03:00Z'
) )
@ -112,9 +76,6 @@ describe('back_fill_file_hash_fix_up script', function () {
const fileIdWithDifferentHashRestore = objectIdFromTime( const fileIdWithDifferentHashRestore = objectIdFromTime(
'2017-02-01T00:08:00Z' '2017-02-01T00:08:00Z'
) )
const fileIdBlobExistsInGCS1 = objectIdFromTime('2017-02-01T00:09:00Z')
const fileIdRestoreFromFilestore0 = objectIdFromTime('2017-02-01T00:10:00Z')
const fileIdRestoreFromFilestore1 = objectIdFromTime('2017-02-01T00:11:00Z')
const fileIdMissing2 = objectIdFromTime('2017-02-01T00:12:00Z') const fileIdMissing2 = objectIdFromTime('2017-02-01T00:12:00Z')
const fileIdHashMissing0 = objectIdFromTime('2017-02-01T00:13:00Z') const fileIdHashMissing0 = objectIdFromTime('2017-02-01T00:13:00Z')
const fileIdHashMissing1 = objectIdFromTime('2017-02-01T00:14:00Z') const fileIdHashMissing1 = objectIdFromTime('2017-02-01T00:14:00Z')
@ -125,31 +86,11 @@ describe('back_fill_file_hash_fix_up script', function () {
) )
const deleteProjectsRecordId0 = new ObjectId() const deleteProjectsRecordId0 = new ObjectId()
const writtenBlobs = [ const writtenBlobs = [
{
projectId: projectId0,
historyId: historyId0,
fileId: fileIdBlobExistsInGCS0,
},
{
projectId: projectId0,
historyId: historyId0,
fileId: fileIdBlobExistsInGCS1,
},
{ {
projectId: projectId0, projectId: projectId0,
historyId: historyId0, historyId: historyId0,
fileId: fileIdWithDifferentHashNotFound0, fileId: fileIdWithDifferentHashNotFound0,
}, },
{
projectId: projectId0,
historyId: historyId0,
fileId: fileIdRestoreFromFilestore0,
},
{
projectId: projectId0,
historyId: historyId0,
fileId: fileIdRestoreFromFilestore1,
},
{ {
projectId: projectId0, projectId: projectId0,
historyId: historyId0, historyId: historyId0,
@ -200,17 +141,6 @@ describe('back_fill_file_hash_fix_up script', function () {
}, },
msg: 'failed to process file', msg: 'failed to process file',
}, },
{
projectId: projectId0,
fileId: fileIdRestoreFromFilestore0,
err: { message: 'OError: hash mismatch' },
hash: gitBlobHash(fileIdRestoreFromFilestore0),
entry: {
ctx: { historyId: historyId0.toString() },
hash: hashDoesNotExistAsBlob,
},
msg: 'failed to process file',
},
{ {
projectId: projectIdDeleted0, projectId: projectIdDeleted0,
fileId: fileIdWithDifferentHashNotFound1, fileId: fileIdWithDifferentHashNotFound1,
@ -236,33 +166,6 @@ describe('back_fill_file_hash_fix_up script', function () {
err: { message: 'NotFoundError' }, err: { message: 'NotFoundError' },
msg: 'failed to process file', msg: 'failed to process file',
}, },
{
projectId: projectId0,
fileId: fileIdBlobExistsInGCS0,
hash: gitBlobHash(fileIdBlobExistsInGCS0),
err: { message: 'storage.objects.delete' },
msg: 'failed to process file',
},
{
projectId: projectId0,
fileId: fileIdBlobExistsInGCSCorrupted,
hash: gitBlobHash(fileIdBlobExistsInGCSCorrupted),
err: { message: 'storage.objects.delete' },
msg: 'failed to process file',
},
{
projectId: projectId0,
fileId: fileIdBlobExistsInGCS1,
hash: gitBlobHash(fileIdBlobExistsInGCS1),
err: { message: 'storage.objects.delete' },
msg: 'failed to process file',
},
{
projectId: projectId0,
fileId: fileIdRestoreFromFilestore1,
err: { message: 'storage.objects.delete' },
msg: 'failed to process file',
},
{ {
projectId: projectIdDeleted0, projectId: projectIdDeleted0,
fileId: fileIdMissing1, fileId: fileIdMissing1,
@ -291,22 +194,23 @@ describe('back_fill_file_hash_fix_up script', function () {
reason: 'bad file hash', reason: 'bad file hash',
msg: 'bad file-tree path', msg: 'bad file-tree path',
}, },
{
projectId: projectId0,
_id: fileIdBlobExistsInGCSCorrupted,
reason: 'bad file hash',
msg: 'bad file-tree path',
},
] ]
if (PRINT_IDS_AND_HASHES_FOR_DEBUGGING) { if (PRINT_IDS_AND_HASHES_FOR_DEBUGGING) {
const fileIds = { const fileIds = {
fileIdWithDifferentHashFound, fileIdWithDifferentHashFound,
fileIdInGoodState, fileIdInGoodState,
fileIdBlobExistsInGCS0,
fileIdBlobExistsInGCS1,
fileIdWithDifferentHashNotFound0, fileIdWithDifferentHashNotFound0,
fileIdWithDifferentHashNotFound1, fileIdWithDifferentHashNotFound1,
fileIdBlobExistsInGCSCorrupted,
fileIdMissing0, fileIdMissing0,
fileIdMissing1, fileIdMissing1,
fileIdMissing2, fileIdMissing2,
fileIdWithDifferentHashRestore, fileIdWithDifferentHashRestore,
fileIdRestoreFromFilestore0,
fileIdRestoreFromFilestore1,
fileIdHashMissing0, fileIdHashMissing0,
fileIdHashMissing1, fileIdHashMissing1,
} }
@ -330,38 +234,25 @@ describe('back_fill_file_hash_fix_up script', function () {
before(cleanup.everything) before(cleanup.everything)
before('populate blobs/GCS', async function () { before('populate blobs/GCS', async function () {
await FILESTORE_PERSISTOR.sendStream( await mockFilestore.start()
USER_FILES_BUCKET_NAME, mockFilestore.addFile(
`${projectId0}/${fileIdRestoreFromFilestore0}`, projectId0,
Stream.Readable.from([fileIdRestoreFromFilestore0.toString()]) fileIdHashMissing0,
fileIdHashMissing0.toString()
) )
await FILESTORE_PERSISTOR.sendStream( mockFilestore.addFile(
USER_FILES_BUCKET_NAME, projectId0,
`${projectId0}/${fileIdRestoreFromFilestore1}`, fileIdHashMissing1,
Stream.Readable.from([fileIdRestoreFromFilestore1.toString()]) fileIdHashMissing1.toString()
) )
await FILESTORE_PERSISTOR.sendStream( mockFilestore.addFile(
USER_FILES_BUCKET_NAME, projectId0,
`${projectId0}/${fileIdHashMissing0}`, fileIdBlobExistsInGCSCorrupted,
Stream.Readable.from([fileIdHashMissing0.toString()]) fileIdBlobExistsInGCSCorrupted.toString()
)
await FILESTORE_PERSISTOR.sendStream(
USER_FILES_BUCKET_NAME,
`${projectId0}/${fileIdHashMissing1}`,
Stream.Readable.from([fileIdHashMissing1.toString()])
) )
await new BlobStore(historyId0.toString()).putString( await new BlobStore(historyId0.toString()).putString(
fileIdHashMissing1.toString() // partially processed fileIdHashMissing1.toString() // partially processed
) )
await new BlobStore(historyId0.toString()).putString(
fileIdBlobExistsInGCS0.toString()
)
await new BlobStore(historyId0.toString()).putString(
fileIdBlobExistsInGCS1.toString()
)
await new BlobStore(historyId0.toString()).putString(
fileIdRestoreFromFilestore1.toString()
)
const path = '/tmp/test-blob-corrupted' const path = '/tmp/test-blob-corrupted'
try { try {
await fs.promises.writeFile(path, contentCorruptedBlob) await fs.promises.writeFile(path, contentCorruptedBlob)
@ -426,22 +317,10 @@ describe('back_fill_file_hash_fix_up script', function () {
_id: fileIdWithDifferentHashNotFound0, _id: fileIdWithDifferentHashNotFound0,
hash: hashDoesNotExistAsBlob, hash: hashDoesNotExistAsBlob,
}, },
{
_id: fileIdRestoreFromFilestore0,
hash: hashDoesNotExistAsBlob,
},
{
_id: fileIdRestoreFromFilestore1,
},
{
_id: fileIdBlobExistsInGCS0,
hash: gitBlobHash(fileIdBlobExistsInGCS0),
},
{ {
_id: fileIdBlobExistsInGCSCorrupted, _id: fileIdBlobExistsInGCSCorrupted,
hash: gitBlobHash(fileIdBlobExistsInGCSCorrupted), hash: gitBlobHash(fileIdBlobExistsInGCSCorrupted),
}, },
{ _id: fileIdBlobExistsInGCS1 },
], ],
folders: [], folders: [],
}, },
@ -546,8 +425,8 @@ describe('back_fill_file_hash_fix_up script', function () {
}) })
it('should print stats', function () { it('should print stats', function () {
expect(stats).to.contain({ expect(stats).to.contain({
processedLines: 16, processedLines: 12,
success: 11, success: 7,
alreadyProcessed: 0, alreadyProcessed: 0,
fileDeleted: 0, fileDeleted: 0,
skipped: 0, skipped: 0,
@ -558,9 +437,9 @@ describe('back_fill_file_hash_fix_up script', function () {
it('should handle re-run on same logs', async function () { it('should handle re-run on same logs', async function () {
;({ stats } = await runScriptWithLogs()) ;({ stats } = await runScriptWithLogs())
expect(stats).to.contain({ expect(stats).to.contain({
processedLines: 16, processedLines: 12,
success: 0, success: 0,
alreadyProcessed: 8, alreadyProcessed: 4,
fileDeleted: 3, fileDeleted: 3,
skipped: 0, skipped: 0,
failed: 3, failed: 3,
@ -663,31 +542,11 @@ describe('back_fill_file_hash_fix_up script', function () {
_id: fileIdWithDifferentHashNotFound0, _id: fileIdWithDifferentHashNotFound0,
hash: gitBlobHash(fileIdWithDifferentHashNotFound0), hash: gitBlobHash(fileIdWithDifferentHashNotFound0),
}, },
// Updated hash
{
_id: fileIdRestoreFromFilestore0,
hash: gitBlobHash(fileIdRestoreFromFilestore0),
},
// Added hash
{
_id: fileIdRestoreFromFilestore1,
hash: gitBlobHash(fileIdRestoreFromFilestore1),
},
// No change, blob created
{
_id: fileIdBlobExistsInGCS0,
hash: gitBlobHash(fileIdBlobExistsInGCS0),
},
// No change, flagged // No change, flagged
{ {
_id: fileIdBlobExistsInGCSCorrupted, _id: fileIdBlobExistsInGCSCorrupted,
hash: gitBlobHash(fileIdBlobExistsInGCSCorrupted), hash: gitBlobHash(fileIdBlobExistsInGCSCorrupted),
}, },
// Added hash
{
_id: fileIdBlobExistsInGCS1,
hash: gitBlobHash(fileIdBlobExistsInGCS1),
},
], ],
folders: [], folders: [],
}, },
@ -696,7 +555,7 @@ describe('back_fill_file_hash_fix_up script', function () {
], ],
overleaf: { history: { id: historyId0 } }, overleaf: { history: { id: historyId0 } },
// Incremented when removing file/updating hash // Incremented when removing file/updating hash
version: 8, version: 5,
}, },
]) ])
expect(await deletedProjectsCollection.find({}).toArray()).to.deep.equal([ expect(await deletedProjectsCollection.find({}).toArray()).to.deep.equal([
@ -745,62 +604,6 @@ describe('back_fill_file_hash_fix_up script', function () {
(writtenBlobsByProject.get(projectId) || []).concat([fileId]) (writtenBlobsByProject.get(projectId) || []).concat([fileId])
) )
} }
expect(
(await backedUpBlobs.find({}, { sort: { _id: 1 } }).toArray()).map(
entry => {
// blobs are pushed unordered into mongo. Sort the list for consistency.
entry.blobs.sort()
return entry
}
)
).to.deep.equal(
Array.from(writtenBlobsByProject.entries()).map(
([projectId, fileIds]) => {
return {
_id: projectId,
blobs: fileIds
.map(fileId => binaryForGitBlobHash(gitBlobHash(fileId)))
.sort(),
}
}
)
)
})
it('should have backed up all the files', async function () {
expect(tieringStorageClass).to.exist
const objects = await listS3Bucket(projectBlobsBucket, tieringStorageClass)
expect(objects.sort()).to.deep.equal(
writtenBlobs
.map(({ historyId, fileId, hash }) =>
makeProjectKey(historyId, hash || gitBlobHash(fileId))
)
.sort()
)
for (let { historyId, fileId } of writtenBlobs) {
const hash = gitBlobHash(fileId.toString())
const s = await backupPersistor.getObjectStream(
projectBlobsBucket,
makeProjectKey(historyId, hash),
{ autoGunzip: true }
)
const buf = new WritableBuffer()
await Stream.promises.pipeline(s, buf)
expect(gitBlobHashBuffer(buf.getContents())).to.equal(hash)
const id = buf.getContents().toString('utf-8')
expect(id).to.equal(fileId.toString())
// double check we are not comparing 'undefined' or '[object Object]' above
expect(id).to.match(/^[a-f0-9]{24}$/)
}
const deks = await listS3Bucket(deksBucket, 'STANDARD')
expect(deks.sort()).to.deep.equal(
Array.from(
new Set(
writtenBlobs.map(
({ historyId }) => projectKey.format(historyId) + '/dek'
)
)
).sort()
)
}) })
it('should have written the back filled files to history v1', async function () { it('should have written the back filled files to history v1', async function () {
for (const { historyId, fileId } of writtenBlobs) { for (const { historyId, fileId } of writtenBlobs) {

View file

@ -0,0 +1,54 @@
import express from 'express'
class MockFilestore {
constructor() {
this.host = process.env.FILESTORE_HOST || '127.0.0.1'
this.port = process.env.FILESTORE_PORT || 3009
// create a server listening on this.host and this.port
this.files = {}
this.app = express()
this.app.get('/project/:projectId/file/:fileId', (req, res) => {
const { projectId, fileId } = req.params
const content = this.files[projectId]?.[fileId]
if (!content) return res.status(404).end()
res.status(200).end(content)
})
}
start() {
// reset stored files
this.files = {}
// start the server
if (this.serverPromise) {
return this.serverPromise
} else {
this.serverPromise = new Promise((resolve, reject) => {
this.server = this.app.listen(this.port, this.host, err => {
if (err) return reject(err)
resolve()
})
})
return this.serverPromise
}
}
addFile(projectId, fileId, fileContent) {
if (!this.files[projectId]) {
this.files[projectId] = {}
}
this.files[projectId][fileId] = fileContent
}
deleteObject(projectId, fileId) {
if (this.files[projectId]) {
delete this.files[projectId][fileId]
if (Object.keys(this.files[projectId]).length === 0) {
delete this.files[projectId]
}
}
}
}
export const mockFilestore = new MockFilestore()

View file

@ -42,7 +42,7 @@ services:
command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs .
user: root user: root
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -44,7 +44,7 @@ services:
command: npm run --silent test:acceptance command: npm run --silent test:acceptance
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -55,7 +55,7 @@ services:
retries: 20 retries: 20
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -57,7 +57,7 @@ services:
retries: 20 retries: 20
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -56,14 +56,8 @@ if (Settings.catchErrors) {
// Create ./data/dumpFolder if needed // Create ./data/dumpFolder if needed
FileWriter.ensureDumpFolderExists() FileWriter.ensureDumpFolderExists()
if ( // Validate combination of feature flags.
!Features.hasFeature('project-history-blobs') && Features.validateSettings()
!Features.hasFeature('filestore')
) {
throw new Error(
'invalid config: must enable either project-history-blobs (Settings.enableProjectHistoryBlobs=true) or enable filestore (Settings.disableFilestore=false)'
)
}
// handle SIGTERM for graceful shutdown in kubernetes // handle SIGTERM for graceful shutdown in kubernetes
process.on('SIGTERM', function (signal) { process.on('SIGTERM', function (signal) {

View file

@ -36,7 +36,22 @@ function send401WithChallenge(res) {
function checkCredentials(userDetailsMap, user, password) { function checkCredentials(userDetailsMap, user, password) {
const expectedPassword = userDetailsMap.get(user) const expectedPassword = userDetailsMap.get(user)
const userExists = userDetailsMap.has(user) && expectedPassword // user exists with a non-null password const userExists = userDetailsMap.has(user) && expectedPassword // user exists with a non-null password
const isValid = userExists && tsscmp(expectedPassword, password)
let isValid = false
if (userExists) {
if (Array.isArray(expectedPassword)) {
const isValidPrimary = Boolean(
expectedPassword[0] && tsscmp(expectedPassword[0], password)
)
const isValidFallback = Boolean(
expectedPassword[1] && tsscmp(expectedPassword[1], password)
)
isValid = isValidPrimary || isValidFallback
} else {
isValid = tsscmp(expectedPassword, password)
}
}
if (!isValid) { if (!isValid) {
logger.err({ user }, 'invalid login details') logger.err({ user }, 'invalid login details')
} }

View file

@ -7,6 +7,7 @@ import logger from '@overleaf/logger'
import _ from 'lodash' import _ from 'lodash'
import { plainTextResponse } from '../../infrastructure/Response.js' import { plainTextResponse } from '../../infrastructure/Response.js'
import { expressify } from '@overleaf/promise-utils' import { expressify } from '@overleaf/promise-utils'
import Modules from '../../infrastructure/Modules.js'
async function getDocument(req, res) { async function getDocument(req, res) {
const { Project_id: projectId, doc_id: docId } = req.params const { Project_id: projectId, doc_id: docId } = req.params
@ -92,6 +93,9 @@ async function setDocument(req, res) {
{ docId, projectId }, { docId, projectId },
'finished receiving set document request from api (docupdater)' 'finished receiving set document request from api (docupdater)'
) )
await Modules.promises.hooks.fire('docModified', projectId, docId)
res.json(result) res.json(result)
} }

View file

@ -8,7 +8,7 @@ function projectHistoryURLWithFilestoreFallback(
) { ) {
const filestoreURL = `${Settings.apis.filestore.url}/project/${projectId}/file/${fileRef._id}?from=${origin}` const filestoreURL = `${Settings.apis.filestore.url}/project/${projectId}/file/${fileRef._id}?from=${origin}`
// TODO: When this file is converted to ES modules we will be able to use Features.hasFeature('project-history-blobs'). Currently we can't stub the feature return value in tests. // TODO: When this file is converted to ES modules we will be able to use Features.hasFeature('project-history-blobs'). Currently we can't stub the feature return value in tests.
if (fileRef.hash && Settings.enableProjectHistoryBlobs) { if (fileRef.hash && Settings.filestoreMigrationLevel >= 1) {
return { return {
url: `${Settings.apis.project_history.url}/project/${historyId}/blob/${fileRef.hash}`, url: `${Settings.apis.project_history.url}/project/${historyId}/blob/${fileRef.hash}`,
fallbackURL: filestoreURL, fallbackURL: filestoreURL,

View file

@ -590,7 +590,7 @@ const _ProjectController = {
} }
const isAdminOrTemplateOwner = const isAdminOrTemplateOwner =
hasAdminAccess(user) || Settings.templates?.user_id === userId hasAdminAccess(user) || Settings.templates?.nonAdminCanManage
const showTemplatesServerPro = const showTemplatesServerPro =
Features.hasFeature('templates-server-pro') && isAdminOrTemplateOwner Features.hasFeature('templates-server-pro') && isAdminOrTemplateOwner

View file

@ -7,21 +7,22 @@ const { expressify } = require('@overleaf/promise-utils')
const TemplatesController = { const TemplatesController = {
async getV1Template(req, res) { async getV1Template(req, res) {
const templateVersionId = req.params.Template_version_id const templateId = req.params.Template_version_id
const templateId = req.query.id const templateVersionId = req.query.version
if (!/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)) { // if (!/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)) {
logger.err( // logger.err(
{ templateVersionId, templateId }, // { templateVersionId, templateId },
'invalid template id or version' // 'invalid template id or version'
) // )
return res.sendStatus(400) // return res.sendStatus(400)
} // }
const data = { const data = {
templateVersionId, templateVersionId,
templateId, templateId,
name: req.query.templateName, name: req.query.name,
compiler: ProjectHelper.compilerFromV1Engine(req.query.latexEngine), compiler: req.query.compiler,
imageName: req.query.texImage, language: req.query.language,
imageName: req.query.imageName,
mainFile: req.query.mainFile, mainFile: req.query.mainFile,
brandVariationId: req.query.brandVariationId, brandVariationId: req.query.brandVariationId,
} }
@ -36,6 +37,7 @@ const TemplatesController = {
async createProjectFromV1Template(req, res) { async createProjectFromV1Template(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
const project = await TemplatesManager.promises.createProjectFromV1Template( const project = await TemplatesManager.promises.createProjectFromV1Template(
req.body.brandVariationId, req.body.brandVariationId,
req.body.compiler, req.body.compiler,
@ -44,7 +46,8 @@ const TemplatesController = {
req.body.templateName, req.body.templateName,
req.body.templateVersionId, req.body.templateVersionId,
userId, userId,
req.body.imageName req.body.imageName,
req.body.language
) )
delete req.session.templateData delete req.session.templateData
if (!project) { if (!project) {

View file

@ -18,6 +18,7 @@ const crypto = require('crypto')
const Errors = require('../Errors/Errors') const Errors = require('../Errors/Errors')
const { pipeline } = require('stream/promises') const { pipeline } = require('stream/promises')
const ClsiCacheManager = require('../Compile/ClsiCacheManager') const ClsiCacheManager = require('../Compile/ClsiCacheManager')
const TIMEOUT = 30000 // 30 sec
const TemplatesManager = { const TemplatesManager = {
async createProjectFromV1Template( async createProjectFromV1Template(
@ -28,25 +29,19 @@ const TemplatesManager = {
templateName, templateName,
templateVersionId, templateVersionId,
userId, userId,
imageName imageName,
language
) { ) {
const zipUrl = `${settings.apis.v1.url}/api/v1/overleaf/templates/${templateVersionId}` const zipUrl = `${settings.apis.filestore.url}/template/${templateId}/v/${templateVersionId}/zip`
const zipReq = await fetchStreamWithResponse(zipUrl, { const zipReq = await fetchStreamWithResponse(zipUrl, {
basicAuth: { signal: AbortSignal.timeout(TIMEOUT),
user: settings.apis.v1.user,
password: settings.apis.v1.pass,
},
signal: AbortSignal.timeout(settings.apis.v1.timeout),
}) })
const projectName = ProjectDetailsHandler.fixProjectName(templateName) const projectName = ProjectDetailsHandler.fixProjectName(templateName)
const dumpPath = `${settings.path.dumpFolder}/${crypto.randomUUID()}` const dumpPath = `${settings.path.dumpFolder}/${crypto.randomUUID()}`
const writeStream = fs.createWriteStream(dumpPath) const writeStream = fs.createWriteStream(dumpPath)
try { try {
const attributes = { const attributes = {}
fromV1TemplateId: templateId,
fromV1TemplateVersionId: templateVersionId,
}
await pipeline(zipReq.stream, writeStream) await pipeline(zipReq.stream, writeStream)
if (zipReq.response.status !== 200) { if (zipReq.response.status !== 200) {
@ -78,14 +73,9 @@ const TemplatesManager = {
await TemplatesManager._setCompiler(project._id, compiler) await TemplatesManager._setCompiler(project._id, compiler)
await TemplatesManager._setImage(project._id, imageName) await TemplatesManager._setImage(project._id, imageName)
await TemplatesManager._setMainFile(project._id, mainFile) await TemplatesManager._setMainFile(project._id, mainFile)
await TemplatesManager._setSpellCheckLanguage(project._id, language)
await TemplatesManager._setBrandVariationId(project._id, brandVariationId) await TemplatesManager._setBrandVariationId(project._id, brandVariationId)
const update = {
fromV1TemplateId: templateId,
fromV1TemplateVersionId: templateVersionId,
}
await Project.updateOne({ _id: project._id }, update, {})
await prepareClsiCacheInBackground await prepareClsiCacheInBackground
return project return project
@ -102,11 +92,12 @@ const TemplatesManager = {
}, },
async _setImage(projectId, imageName) { async _setImage(projectId, imageName) {
if (!imageName) { try {
imageName = 'wl_texlive:2018.1' await ProjectOptionsHandler.setImageName(projectId, imageName)
} catch {
logger.warn({ imageName: imageName }, 'not available')
await ProjectOptionsHandler.setImageName(projectId, settings.currentImageName)
} }
await ProjectOptionsHandler.setImageName(projectId, imageName)
}, },
async _setMainFile(projectId, mainFile) { async _setMainFile(projectId, mainFile) {
@ -116,6 +107,13 @@ const TemplatesManager = {
await ProjectRootDocManager.setRootDocFromName(projectId, mainFile) await ProjectRootDocManager.setRootDocFromName(projectId, mainFile)
}, },
async _setSpellCheckLanguage(projectId, language) {
if (language == null) {
return
}
await ProjectOptionsHandler.setSpellCheckLanguage(projectId, language)
},
async _setBrandVariationId(projectId, brandVariationId) { async _setBrandVariationId(projectId, brandVariationId) {
if (brandVariationId == null) { if (brandVariationId == null) {
return return

View file

@ -66,7 +66,7 @@ function uploadProject(req, res, next) {
async function uploadFile(req, res, next) { async function uploadFile(req, res, next) {
const timer = new metrics.Timer('file-upload') const timer = new metrics.Timer('file-upload')
const name = req.body.name const name = req.body.name
const path = req.file?.path const { path } = req.file
const projectId = req.params.Project_id const projectId = req.params.Project_id
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
let { folder_id: folderId } = req.query let { folder_id: folderId } = req.query
@ -162,8 +162,14 @@ function multerMiddleware(req, res, next) {
.status(422) .status(422)
.json({ success: false, error: req.i18n.translate('file_too_large') }) .json({ success: false, error: req.i18n.translate('file_too_large') })
} }
if (err) return next(err)
return next(err) if (!req.file?.path) {
logger.info({ req }, 'missing req.file.path on upload')
return res
.status(400)
.json({ success: false, error: 'invalid_upload_request' })
}
next()
}) })
} }

View file

@ -434,7 +434,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
labsEnabled: Settings.labs && Settings.labs.enable, labsEnabled: Settings.labs && Settings.labs.enable,
wikiEnabled: Settings.overleaf != null || Settings.proxyLearn, wikiEnabled: Settings.overleaf != null || Settings.proxyLearn,
templatesEnabled: templatesEnabled:
Settings.overleaf != null || Settings.templates?.user_id != null, Settings.overleaf != null || Boolean(Settings.templates),
cioWriteKey: Settings.analytics?.cio?.writeKey, cioWriteKey: Settings.analytics?.cio?.writeKey,
cioSiteId: Settings.analytics?.cio?.siteId, cioSiteId: Settings.analytics?.cio?.siteId,
} }

View file

@ -19,8 +19,7 @@ const trackChangesModuleAvailable =
* @property {boolean | undefined} enableGithubSync * @property {boolean | undefined} enableGithubSync
* @property {boolean | undefined} enableGitBridge * @property {boolean | undefined} enableGitBridge
* @property {boolean | undefined} enableHomepage * @property {boolean | undefined} enableHomepage
* @property {boolean | undefined} enableProjectHistoryBlobs * @property {number} filestoreMigrationLevel
* @property {boolean | undefined} disableFilestore
* @property {boolean | undefined} enableSaml * @property {boolean | undefined} enableSaml
* @property {boolean | undefined} ldap * @property {boolean | undefined} ldap
* @property {boolean | undefined} oauth * @property {boolean | undefined} oauth
@ -30,6 +29,14 @@ const trackChangesModuleAvailable =
*/ */
const Features = { const Features = {
validateSettings() {
if (![0, 1, 2].includes(Settings.filestoreMigrationLevel)) {
throw new Error(
`invalid OVERLEAF_FILESTORE_MIGRATION_LEVEL=${Settings.filestoreMigrationLevel}, expected 0, 1 or 2`
)
}
},
/** /**
* @returns {boolean} * @returns {boolean}
*/ */
@ -69,7 +76,7 @@ const Features = {
case 'oauth': case 'oauth':
return Boolean(Settings.oauth) return Boolean(Settings.oauth)
case 'templates-server-pro': case 'templates-server-pro':
return Boolean(Settings.templates?.user_id) return Boolean(Settings.templates)
case 'affiliations': case 'affiliations':
case 'analytics': case 'analytics':
return Boolean(_.get(Settings, ['apis', 'v1', 'url'])) return Boolean(_.get(Settings, ['apis', 'v1', 'url']))
@ -89,9 +96,9 @@ const Features = {
Settings.enabledLinkedFileTypes.includes('url') Settings.enabledLinkedFileTypes.includes('url')
) )
case 'project-history-blobs': case 'project-history-blobs':
return Boolean(Settings.enableProjectHistoryBlobs) return Settings.filestoreMigrationLevel > 0
case 'filestore': case 'filestore':
return Boolean(Settings.disableFilestore) === false return Settings.filestoreMigrationLevel < 2
case 'support': case 'support':
return supportModuleAvailable return supportModuleAvailable
case 'symbol-palette': case 'symbol-palette':

View file

@ -150,8 +150,7 @@ async function linkedFileAgentsIncludes() {
async function attachHooks() { async function attachHooks() {
for (const module of await modules()) { for (const module of await modules()) {
const { promises, ...hooks } = module.hooks || {} const { promises, ...hooks } = module.hooks || {}
for (const hook in promises || {}) { for (const [hook, method] of Object.entries(promises || {})) {
const method = promises[hook]
attachHook(hook, method) attachHook(hook, method)
} }
for (const hook in hooks || {}) { for (const hook in hooks || {}) {

View file

@ -262,6 +262,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
'/read-only/one-time-login' '/read-only/one-time-login'
) )
await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
webRouter.post('/logout', UserController.logout) webRouter.post('/logout', UserController.logout)
webRouter.get('/restricted', AuthorizationMiddleware.restricted) webRouter.get('/restricted', AuthorizationMiddleware.restricted)
@ -285,8 +287,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
TokenAccessRouter.apply(webRouter) TokenAccessRouter.apply(webRouter)
HistoryRouter.apply(webRouter, privateApiRouter) HistoryRouter.apply(webRouter, privateApiRouter)
await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
if (Settings.enableSubscriptions) { if (Settings.enableSubscriptions) {
webRouter.get( webRouter.get(
'/user/bonus', '/user/bonus',

View file

@ -1,13 +1,13 @@
section.cookie-banner.hidden-print.hidden(aria-label='Cookie banner') section.cookie-banner.hidden-print.hidden(aria-label=translate('cookie_banner'))
.cookie-banner-content We only use cookies for essential purposes and to improve your experience on our site. You can find out more in our <a href="/legal#Cookies">cookie policy</a>. .cookie-banner-content !{translate('cookie_banner_info', {}, [{ name: 'a', attrs: { href: '/legal#Cookies' }}])}
.cookie-banner-actions .cookie-banner-actions
button( button(
type='button' type='button'
class='btn btn-link btn-sm' class='btn btn-link btn-sm'
data-ol-cookie-banner-set-consent='essential' data-ol-cookie-banner-set-consent='essential'
) Essential cookies only ) #{translate('essential_cookies_only')}
button( button(
type='button' type='button'
class='btn btn-primary btn-sm' class='btn btn-primary btn-sm'
data-ol-cookie-banner-set-consent='all' data-ol-cookie-banner-set-consent='all'
) Accept all cookies ) #{translate('accept_all_cookies')}

View file

@ -4,7 +4,7 @@ block vars
- var suppressNavbar = true - var suppressNavbar = true
- var suppressFooter = true - var suppressFooter = true
- var suppressSkipToContent = true - var suppressSkipToContent = true
- var suppressCookieBanner = true - var suppressPugCookieBanner = true
block content block content
.content.content-alt .content.content-alt

View file

@ -24,7 +24,7 @@ block body
else else
include layout/fat-footer include layout/fat-footer
if typeof suppressCookieBanner == 'undefined' if typeof suppressPugCookieBanner == 'undefined'
include _cookie_banner include _cookie_banner
if bootstrapVersion === 5 if bootstrapVersion === 5

View file

@ -69,5 +69,5 @@ block body
else else
include layout/fat-footer-react-bootstrap-5 include layout/fat-footer-react-bootstrap-5
if typeof suppressCookieBanner === 'undefined' if typeof suppressPugCookieBanner === 'undefined'
include _cookie_banner include _cookie_banner

View file

@ -27,7 +27,7 @@ block body
else else
include layout/fat-footer-website-redesign include layout/fat-footer-website-redesign
if typeof suppressCookieBanner == 'undefined' if typeof suppressPugCookieBanner == 'undefined'
include _cookie_banner include _cookie_banner
block contactModal block contactModal

View file

@ -161,6 +161,18 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(
event-segmentation={page: currentUrl, item: 'register', location: 'top-menu'} event-segmentation={page: currentUrl, item: 'register', location: 'top-menu'}
) #{translate('sign_up')} ) #{translate('sign_up')}
// templates link
if settings.templates
+nav-item
+nav-link(
href="/templates"
event-tracking="menu-click"
event-tracking-action="clicked"
event-tracking-trigger="click"
event-tracking-mb="true"
event-segmentation={ page: currentUrl, item: 'templates', location: 'top-menu' }
) #{translate('templates')}
// login link // login link
+nav-item +nav-item
+nav-link( +nav-link(

View file

@ -159,6 +159,18 @@ nav.navbar.navbar-default.navbar-main(
// logged out // logged out
if !getSessionUser() if !getSessionUser()
// templates link
if settings.templates
li
a(
href="/templates"
event-tracking="menu-click"
event-tracking-action="clicked"
event-tracking-trigger="click"
event-tracking-mb="true"
event-segmentation={ page: currentUrl, item: 'templates', location: 'top-menu' }
) #{translate('templates')}
// register link // register link
if hasFeature('registration-page') if hasFeature('registration-page')
li.primary li.primary

View file

@ -2,7 +2,7 @@ extends ../../layout-marketing
block vars block vars
- var suppressFooter = true - var suppressFooter = true
- var suppressCookieBanner = true - var suppressPugCookieBanner = true
- var suppressSkipToContent = true - var suppressSkipToContent = true
block content block content
@ -29,8 +29,10 @@ block content
input(type="hidden" name="templateVersionId" value=templateVersionId) input(type="hidden" name="templateVersionId" value=templateVersionId)
input(type="hidden" name="templateName" value=name) input(type="hidden" name="templateName" value=name)
input(type="hidden" name="compiler" value=compiler) input(type="hidden" name="compiler" value=compiler)
input(type="hidden" name="imageName" value=imageName) if imageName
input(type="hidden" name="imageName" value=imageName)
input(type="hidden" name="mainFile" value=mainFile) input(type="hidden" name="mainFile" value=mainFile)
input(type="hidden" name="language" value=language)
if brandVariationId if brandVariationId
input(type="hidden" name="brandVariationId" value=brandVariationId) input(type="hidden" name="brandVariationId" value=brandVariationId)
input(hidden type="submit") input(hidden type="submit")

View file

@ -7,7 +7,7 @@ block vars
- var suppressNavbar = true - var suppressNavbar = true
- var suppressFooter = true - var suppressFooter = true
- var suppressSkipToContent = true - var suppressSkipToContent = true
- var suppressCookieBanner = true - var suppressPugCookieBanner = true
- metadata.robotsNoindexNofollow = true - metadata.robotsNoindexNofollow = true
block content block content

View file

@ -7,6 +7,7 @@ block vars
- const suppressNavContentLinks = true - const suppressNavContentLinks = true
- const suppressNavbar = true - const suppressNavbar = true
- const suppressFooter = true - const suppressFooter = true
- const suppressPugCookieBanner = true
block append meta block append meta
meta( meta(

View file

@ -5,7 +5,7 @@ block entrypointVar
block vars block vars
- var suppressFooter = true - var suppressFooter = true
- var suppressCookieBanner = true - var suppressPugCookieBanner = true
- var suppressSkipToContent = true - var suppressSkipToContent = true
block append meta block append meta

View file

@ -5,7 +5,7 @@ block entrypointVar
block vars block vars
- var suppressFooter = true - var suppressFooter = true
- var suppressCookieBanner = true - var suppressPugCookieBanner = true
- var suppressSkipToContent = true - var suppressSkipToContent = true
block append meta block append meta

View file

@ -0,0 +1,18 @@
extends ../layout-react
block entrypointVar
- entrypoint = 'pages/template-gallery'
block vars
block vars
- const suppressNavContentLinks = true
- const suppressNavbar = true
- const suppressFooter = true
- bootstrap5PageStatus = 'enabled' // One of 'disabled', 'enabled', and 'queryStringOnly'
- isWebsiteRedesign = false
block append meta
meta(name="ol-templateCategory" data-type="string" content=category)
block content
#template-gallery-root

View file

@ -0,0 +1,20 @@
extends ../layout-react
block entrypointVar
- entrypoint = 'pages/template'
block vars
- const suppressNavContentLinks = true
- const suppressNavbar = true
- const suppressFooter = true
- bootstrap5PageStatus = 'enabled' // One of 'disabled', 'enabled', and 'queryStringOnly'
- isWebsiteRedesign = false
block append meta
meta(name="ol-template" data-type="json" content=template)
meta(name="ol-languages" data-type="json" content=languages)
meta(name="ol-userIsAdmin" data-type="boolean" content=hasAdminAccess())
block content
#template-root

View file

@ -440,6 +440,9 @@ module.exports = {
',' ','
), ),
filestoreMigrationLevel:
parseInt(process.env.OVERLEAF_FILESTORE_MIGRATION_LEVEL, 10) || 0,
// i18n // i18n
// ------ // ------
// //
@ -989,7 +992,7 @@ module.exports = {
importProjectFromGithubModalWrapper: [], importProjectFromGithubModalWrapper: [],
importProjectFromGithubMenu: [], importProjectFromGithubMenu: [],
editorLeftMenuSync: [], editorLeftMenuSync: [],
editorLeftMenuManageTemplate: [], editorLeftMenuManageTemplate: ['@/features/editor-left-menu/components/actions-manage-template'],
oauth2Server: [], oauth2Server: [],
managedGroupSubscriptionEnrollmentNotification: [], managedGroupSubscriptionEnrollmentNotification: [],
managedGroupEnrollmentInvite: [], managedGroupEnrollmentInvite: [],
@ -1030,6 +1033,7 @@ module.exports = {
'launchpad', 'launchpad',
'server-ce-scripts', 'server-ce-scripts',
'user-activate', 'user-activate',
'template-gallery',
], ],
viewIncludes: {}, viewIncludes: {},

View file

@ -95,7 +95,7 @@ services:
image: redis:7.4.3 image: redis:7.4.3
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
logging: logging:
driver: none driver: none
command: --replSet overleaf command: --replSet overleaf

View file

@ -91,7 +91,7 @@ services:
image: redis:7.4.3 image: redis:7.4.3
mongo: mongo:
image: mongo:7.0.20 image: mongo:8.0.11
command: --replSet overleaf command: --replSet overleaf
volumes: volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js

View file

@ -26,6 +26,7 @@
"about_to_delete_cert": "", "about_to_delete_cert": "",
"about_to_delete_projects": "", "about_to_delete_projects": "",
"about_to_delete_tag": "", "about_to_delete_tag": "",
"about_to_delete_template": "",
"about_to_delete_the_following_project": "", "about_to_delete_the_following_project": "",
"about_to_delete_the_following_projects": "", "about_to_delete_the_following_projects": "",
"about_to_delete_user_preamble": "", "about_to_delete_user_preamble": "",
@ -35,6 +36,7 @@
"about_to_remove_user_preamble": "", "about_to_remove_user_preamble": "",
"about_to_trash_projects": "", "about_to_trash_projects": "",
"abstract": "", "abstract": "",
"accept_all_cookies": "",
"accept_and_continue": "", "accept_and_continue": "",
"accept_change": "", "accept_change": "",
"accept_change_error_description": "", "accept_change_error_description": "",
@ -129,6 +131,7 @@
"all_premium_features_including": "", "all_premium_features_including": "",
"all_projects": "", "all_projects": "",
"all_projects_will_be_transferred_immediately": "", "all_projects_will_be_transferred_immediately": "",
"all_templates": "",
"all_these_experiments_are_available_exclusively": "", "all_these_experiments_are_available_exclusively": "",
"allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "",
"an_email_has_already_been_sent_to": "", "an_email_has_already_been_sent_to": "",
@ -159,6 +162,7 @@
"ask_repo_owner_to_reconnect": "", "ask_repo_owner_to_reconnect": "",
"ask_repo_owner_to_renew_overleaf_subscription": "", "ask_repo_owner_to_renew_overleaf_subscription": "",
"at_most_x_libraries_can_be_selected": "", "at_most_x_libraries_can_be_selected": "",
"author": "",
"auto_close_brackets": "", "auto_close_brackets": "",
"auto_compile": "", "auto_compile": "",
"auto_complete": "", "auto_complete": "",
@ -222,6 +226,8 @@
"card_must_be_authenticated_by_3dsecure": "", "card_must_be_authenticated_by_3dsecure": "",
"card_payment": "", "card_payment": "",
"careers": "", "careers": "",
"categories": "",
"category": "",
"category_arrows": "", "category_arrows": "",
"category_greek": "", "category_greek": "",
"category_misc": "", "category_misc": "",
@ -332,6 +338,8 @@
"continue_to": "", "continue_to": "",
"continue_using_free_features": "", "continue_using_free_features": "",
"continue_with_free_plan": "", "continue_with_free_plan": "",
"cookie_banner": "",
"cookie_banner_info": "",
"copied": "", "copied": "",
"copy": "", "copy": "",
"copy_code": "", "copy_code": "",
@ -363,6 +371,7 @@
"customize_your_group_subscription": "", "customize_your_group_subscription": "",
"customizing_figures": "", "customizing_figures": "",
"customizing_tables": "", "customizing_tables": "",
"date": "",
"date_and_owner": "", "date_and_owner": "",
"dealing_with_errors": "", "dealing_with_errors": "",
"decrease_indent": "", "decrease_indent": "",
@ -388,6 +397,7 @@
"delete_sso_config": "", "delete_sso_config": "",
"delete_table": "", "delete_table": "",
"delete_tag": "", "delete_tag": "",
"delete_template": "",
"delete_token": "", "delete_token": "",
"delete_user": "", "delete_user": "",
"delete_your_account": "", "delete_your_account": "",
@ -488,6 +498,7 @@
"edit_figure": "", "edit_figure": "",
"edit_sso_configuration": "", "edit_sso_configuration": "",
"edit_tag": "", "edit_tag": "",
"edit_template": "",
"edit_your_custom_dictionary": "", "edit_your_custom_dictionary": "",
"editing": "", "editing": "",
"editing_captions": "", "editing_captions": "",
@ -544,6 +555,7 @@
"error_opening_document_detail": "", "error_opening_document_detail": "",
"error_performing_request": "", "error_performing_request": "",
"error_processing_file": "", "error_processing_file": "",
"essential_cookies_only": "",
"example_project": "", "example_project": "",
"existing_plan_active_until_term_end": "", "existing_plan_active_until_term_end": "",
"expand": "", "expand": "",
@ -863,6 +875,7 @@
"invalid_password_too_similar": "", "invalid_password_too_similar": "",
"invalid_regular_expression": "", "invalid_regular_expression": "",
"invalid_request": "", "invalid_request": "",
"invalid_upload_request": "",
"invite": "", "invite": "",
"invite_expired": "", "invite_expired": "",
"invite_more_collabs": "", "invite_more_collabs": "",
@ -908,6 +921,7 @@
"last_name": "", "last_name": "",
"last_resort_trouble_shooting_guide": "", "last_resort_trouble_shooting_guide": "",
"last_suggested_fix": "", "last_suggested_fix": "",
"last_updated": "",
"last_updated_date_by_x": "", "last_updated_date_by_x": "",
"last_used": "", "last_used": "",
"latam_discount_modal_info": "", "latam_discount_modal_info": "",
@ -916,6 +930,8 @@
"latex_in_thirty_minutes": "", "latex_in_thirty_minutes": "",
"latex_places_figures_according_to_a_special_algorithm": "", "latex_places_figures_according_to_a_special_algorithm": "",
"latex_places_tables_according_to_a_special_algorithm": "", "latex_places_tables_according_to_a_special_algorithm": "",
"latex_templates": "",
"latex_templates_for_journal_articles": "",
"layout": "", "layout": "",
"layout_options": "", "layout_options": "",
"layout_processing": "", "layout_processing": "",
@ -938,7 +954,8 @@
"let_us_know_what_you_think": "", "let_us_know_what_you_think": "",
"lets_get_those_premium_features": "", "lets_get_those_premium_features": "",
"library": "", "library": "",
"licenses": "", "license": "",
"license_for_educational_purposes_confirmation": "",
"limited_document_history": "", "limited_document_history": "",
"limited_offer": "", "limited_offer": "",
"limited_to_n_collaborators_per_project": "", "limited_to_n_collaborators_per_project": "",
@ -1125,6 +1142,7 @@
"no_selection_select_file": "", "no_selection_select_file": "",
"no_symbols_found": "", "no_symbols_found": "",
"no_thanks_cancel_now": "", "no_thanks_cancel_now": "",
"no_templates_found": "",
"normal": "", "normal": "",
"normally_x_price_per_month": "", "normally_x_price_per_month": "",
"normally_x_price_per_year": "", "normally_x_price_per_year": "",
@ -1156,6 +1174,7 @@
"only_importer_can_refresh": "", "only_importer_can_refresh": "",
"open_action_menu": "", "open_action_menu": "",
"open_advanced_reference_search": "", "open_advanced_reference_search": "",
"open_as_template": "",
"open_file": "", "open_file": "",
"open_link": "", "open_link": "",
"open_path": "", "open_path": "",
@ -1179,6 +1198,7 @@
"overleaf_is_easy_to_use": "", "overleaf_is_easy_to_use": "",
"overleaf_labs": "", "overleaf_labs": "",
"overleaf_logo": "", "overleaf_logo": "",
"overleaf_template_gallery": "",
"overleafs_functionality_meets_my_needs": "", "overleafs_functionality_meets_my_needs": "",
"overview": "", "overview": "",
"overwrite": "", "overwrite": "",
@ -1245,6 +1265,7 @@
"please_change_primary_to_remove": "", "please_change_primary_to_remove": "",
"please_check_your_inbox_to_confirm": "", "please_check_your_inbox_to_confirm": "",
"please_compile_pdf_before_download": "", "please_compile_pdf_before_download": "",
"please_compile_pdf_before_publish_as_template": "",
"please_compile_pdf_before_word_count": "", "please_compile_pdf_before_word_count": "",
"please_confirm_primary_email_or_edit": "", "please_confirm_primary_email_or_edit": "",
"please_confirm_secondary_email_or_edit": "", "please_confirm_secondary_email_or_edit": "",
@ -1277,6 +1298,7 @@
"premium_feature": "", "premium_feature": "",
"premium_plan_label": "", "premium_plan_label": "",
"presentation_mode": "", "presentation_mode": "",
"prev": "",
"previous_page": "", "previous_page": "",
"price": "", "price": "",
"primarily_work_study_question": "", "primarily_work_study_question": "",
@ -1738,6 +1760,7 @@
"tell_the_project_owner_and_ask_them_to_upgrade": "", "tell_the_project_owner_and_ask_them_to_upgrade": "",
"template": "", "template": "",
"template_description": "", "template_description": "",
"template_gallery": "",
"template_title_taken_from_project_title": "", "template_title_taken_from_project_title": "",
"templates": "", "templates": "",
"temporarily_hides_the_preview": "", "temporarily_hides_the_preview": "",

View file

@ -1,53 +0,0 @@
import getMeta from '@/utils/meta'
function loadGA() {
if (window.olLoadGA) {
window.olLoadGA()
}
}
function setConsent(value) {
document.querySelector('.cookie-banner').classList.add('hidden')
const cookieDomain = getMeta('ol-ExposedSettings').cookieDomain
const oneYearInSeconds = 60 * 60 * 24 * 365
const cookieAttributes =
'; path=/' +
'; domain=' +
cookieDomain +
'; max-age=' +
oneYearInSeconds +
'; SameSite=Lax; Secure'
if (value === 'all') {
document.cookie = 'oa=1' + cookieAttributes
loadGA()
window.dispatchEvent(new CustomEvent('cookie-consent', { detail: true }))
} else {
document.cookie = 'oa=0' + cookieAttributes
window.dispatchEvent(new CustomEvent('cookie-consent', { detail: false }))
}
}
if (
getMeta('ol-ExposedSettings').gaToken ||
getMeta('ol-ExposedSettings').gaTokenV4 ||
getMeta('ol-ExposedSettings').propensityId ||
getMeta('ol-ExposedSettings').hotjarId
) {
document
.querySelectorAll('[data-ol-cookie-banner-set-consent]')
.forEach(el => {
el.addEventListener('click', function (e) {
e.preventDefault()
const consentType = el.getAttribute('data-ol-cookie-banner-set-consent')
setConsent(consentType)
})
})
const oaCookie = document.cookie.split('; ').find(c => c.startsWith('oa='))
if (!oaCookie) {
const cookieBannerEl = document.querySelector('.cookie-banner')
if (cookieBannerEl) {
cookieBannerEl.classList.remove('hidden')
}
}
}

View file

@ -0,0 +1,32 @@
import {
CookieConsentValue,
cookieBannerRequired,
hasMadeCookieChoice,
setConsent,
} from '@/features/cookie-banner/utils'
function toggleCookieBanner(hidden: boolean) {
const cookieBannerEl = document.querySelector('.cookie-banner')
if (cookieBannerEl) {
cookieBannerEl.classList.toggle('hidden', hidden)
}
}
if (cookieBannerRequired()) {
document
.querySelectorAll('[data-ol-cookie-banner-set-consent]')
.forEach(el => {
el.addEventListener('click', function (e) {
e.preventDefault()
toggleCookieBanner(true)
const consentType = el.getAttribute(
'data-ol-cookie-banner-set-consent'
) as CookieConsentValue | null
setConsent(consentType)
})
})
if (!hasMadeCookieChoice()) {
toggleCookieBanner(false)
}
}

View file

@ -0,0 +1,43 @@
import getMeta from '@/utils/meta'
export type CookieConsentValue = 'all' | 'essential'
function loadGA() {
if (window.olLoadGA) {
window.olLoadGA()
}
}
export function setConsent(value: CookieConsentValue | null) {
const cookieDomain = getMeta('ol-ExposedSettings').cookieDomain
const oneYearInSeconds = 60 * 60 * 24 * 365
const cookieAttributes =
'; path=/' +
'; domain=' +
cookieDomain +
'; max-age=' +
oneYearInSeconds +
'; SameSite=Lax; Secure'
if (value === 'all') {
document.cookie = 'oa=1' + cookieAttributes
loadGA()
window.dispatchEvent(new CustomEvent('cookie-consent', { detail: true }))
} else {
document.cookie = 'oa=0' + cookieAttributes
window.dispatchEvent(new CustomEvent('cookie-consent', { detail: false }))
}
}
export function cookieBannerRequired() {
const exposedSettings = getMeta('ol-ExposedSettings')
return Boolean(
exposedSettings.gaToken ||
exposedSettings.gaTokenV4 ||
exposedSettings.propensityId ||
exposedSettings.hotjarId
)
}
export function hasMadeCookieChoice() {
return document.cookie.split('; ').some(c => c.startsWith('oa='))
}

View file

@ -0,0 +1,72 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../infrastructure/event-tracking'
import getMeta from '../../../utils/meta'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { useDetachCompileContext } from '../../../shared/context/detach-compile-context'
import EditorManageTemplateModalWrapper from '../../template/components/manage-template-modal/editor-manage-template-modal-wrapper'
import LeftMenuButton from './left-menu-button'
type TemplateManageResponse = {
template_id: string
}
export default function ActionsManageTemplate() {
const templatesAdmin = getMeta('ol-showTemplatesServerPro')
if (!templatesAdmin) {
return null
}
const [showModal, setShowModal] = useState(false)
const { pdfFile } = useDetachCompileContext()
const { t } = useTranslation()
const handleShowModal = useCallback(() => {
eventTracking.sendMB('left-menu-template')
setShowModal(true)
}, [])
const openTemplate = useCallback(
({ template_id: templateId }: TemplateManageResponse) => {
location.assign(`/template/${templateId}`)
},
[location]
)
return (
<>
{pdfFile ? (
<LeftMenuButton onClick={handleShowModal} icon='open_in_new'>
{t('publish_as_template')}
</LeftMenuButton>
) : (
<OLTooltip
id="disabled-publish-as-template"
description={t('please_compile_pdf_before_publish_as_template')}
overlayProps={{
placement: 'top',
}}
>
{/* OverlayTrigger won't fire unless the child is a non-react html element (e.g div, span) */}
<div>
<LeftMenuButton
icon='open_in_new'
disabled
disabledAccesibilityText={t(
'please_compile_pdf_before_publish_as_template'
)}
>
{t('publish_as_template')}
</LeftMenuButton>
</div>
</OLTooltip>
)}
<EditorManageTemplateModalWrapper
show={showModal}
handleHide={() => setShowModal(false)}
openTemplate={openTemplate}
/>
</>
)
}

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next' import { useTranslation, Trans } from 'react-i18next'
import { FetchError } from '../../../../infrastructure/fetch-json' import { FetchError } from '../../../../infrastructure/fetch-json'
import RedirectToLogin from './redirect-to-login' import RedirectToLogin from './redirect-to-login'
import { import {
@ -7,6 +7,7 @@ import {
InvalidFilenameError, InvalidFilenameError,
} from '../../errors' } from '../../errors'
import DangerMessage from './danger-message' import DangerMessage from './danger-message'
import getMeta from '@/utils/meta'
// TODO: Update the error type when we properly type FileTreeActionableContext // TODO: Update the error type when we properly type FileTreeActionableContext
export default function ErrorMessage({ export default function ErrorMessage({
@ -15,6 +16,7 @@ export default function ErrorMessage({
error: string | Record<string, any> error: string | Record<string, any>
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isOverleaf } = getMeta('ol-ExposedSettings')
const fileNameLimit = 150 const fileNameLimit = 150
// the error is a string // the error is a string
@ -46,6 +48,22 @@ export default function ErrorMessage({
</DangerMessage> </DangerMessage>
) )
case 'invalid_upload_request':
if (!isOverleaf) {
return (
<DangerMessage>{t('generic_something_went_wrong')}</DangerMessage>
)
}
return (
<DangerMessage>
<Trans
i18nKey="invalid_upload_request"
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
components={[<a href="/contact" target="_blank" />]}
/>
</DangerMessage>
)
case 'duplicate_file_name': case 'duplicate_file_name':
return ( return (
<DangerMessage> <DangerMessage>

View file

@ -1,15 +1,14 @@
import { MessageProps } from '@/features/chat/components/message' import { MessageProps } from '@/features/chat/components/message'
import { User } from '../../../../../../types/user' import { User } from '../../../../../../types/user'
import { getHueForUserId } from '@/shared/utils/colors' import {
getBackgroundColorForUserId,
hslStringToLuminance,
} from '@/shared/utils/colors'
import MessageContent from '@/features/chat/components/message-content' import MessageContent from '@/features/chat/components/message-content'
import classNames from 'classnames' import classNames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon' import MaterialIcon from '@/shared/components/material-icon'
import { t } from 'i18next' import { t } from 'i18next'
function hue(user?: User) {
return user ? getHueForUserId(user.id) : 0
}
function getAvatarStyle(user?: User) { function getAvatarStyle(user?: User) {
if (!user?.id) { if (!user?.id) {
// Deleted user // Deleted user
@ -20,9 +19,15 @@ function getAvatarStyle(user?: User) {
} }
} }
const backgroundColor = getBackgroundColorForUserId(user.id)
return { return {
borderColor: `hsl(${hue(user)}, 85%, 40%)`, borderColor: backgroundColor,
backgroundColor: `hsl(${hue(user)}, 85%, 40%`, backgroundColor,
color:
hslStringToLuminance(backgroundColor) < 0.5
? 'var(--content-primary-dark)'
: 'var(--content-primary)',
} }
} }

View file

@ -7,7 +7,11 @@ import {
DropdownToggle, DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu' } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { getBackgroundColorForUserId } from '@/shared/utils/colors' import {
getBackgroundColorForUserId,
hslStringToLuminance,
} from '@/shared/utils/colors'
import classNames from 'classnames'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -86,9 +90,16 @@ const OnlineUserWidget = ({
const OnlineUserCircle = ({ user }: { user: OnlineUser }) => { const OnlineUserCircle = ({ user }: { user: OnlineUser }) => {
const backgroundColor = getBackgroundColorForUserId(user.user_id) const backgroundColor = getBackgroundColorForUserId(user.user_id)
const luminance = hslStringToLuminance(backgroundColor)
const [character] = [...user.name] const [character] = [...user.name]
return ( return (
<span className="online-user-circle" style={{ backgroundColor }}> <span
className={classNames('online-user-circle', {
'online-user-circle-light-font': luminance < 0.5,
'online-user-circle-dark-font': luminance >= 0.5,
})}
style={{ backgroundColor }}
>
{character} {character}
</span> </span>
) )

View file

@ -20,6 +20,7 @@ import Footer from '@/features/ui/components/bootstrap-5/footer/footer'
import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav' import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav'
import SystemMessages from '@/shared/components/system-messages' import SystemMessages from '@/shared/components/system-messages'
import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg' import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
import CookieBanner from '@/shared/components/cookie-banner'
export function ProjectListDsNav() { export function ProjectListDsNav() {
const navbarProps = getMeta('ol-navbar') const navbarProps = getMeta('ol-navbar')
@ -125,6 +126,7 @@ export function ProjectListDsNav() {
</div> </div>
<Footer {...footerProps} /> <Footer {...footerProps} />
</div> </div>
<CookieBanner />
</div> </div>
</main> </main>
</div> </div>

View file

@ -18,6 +18,7 @@ import Footer from '@/features/ui/components/bootstrap-5/footer/footer'
import WelcomePageContent from '@/features/project-list/components/welcome-page-content' import WelcomePageContent from '@/features/project-list/components/welcome-page-content'
import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav' import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav'
import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav' import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav'
import CookieBanner from '@/shared/components/cookie-banner'
function ProjectListRoot() { function ProjectListRoot() {
const { isReady } = useWaitForI18n() const { isReady } = useWaitForI18n()
@ -88,9 +89,12 @@ function ProjectListPageContent() {
if (totalProjectsCount === 0) { if (totalProjectsCount === 0) {
return ( return (
<DefaultPageContentWrapper> <>
<WelcomePageContent /> <DefaultPageContentWrapper>
</DefaultPageContentWrapper> <WelcomePageContent />
</DefaultPageContentWrapper>
<CookieBanner />
</>
) )
} }
return ( return (

View file

@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
export default function GalleryHeaderAll() {
const { t } = useTranslation()
return (
<div className="gallery-header">
<OLRow>
<OLCol md={12}>
<h1 className="gallery-title">
<span className="eyebrow-text">
<span aria-hidden="true">&#123;</span>
<span>{t('overleaf_template_gallery')}</span>
<span aria-hidden="true">&#125;</span>
</span>
{t('latex_templates')}
</h1>
</OLCol>
</OLRow>
<div className="row">
<div className="col-md-12">
<p className="gallery-summary">{t('latex_templates_for_journal_articles')}
</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,33 @@
import getMeta from '@/utils/meta'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
import GallerySearchSortHeader from './gallery-search-sort-header'
export default function GalleryHeaderTagged({ category }) {
const title = getMeta('og:title')
const { templateLinks } = getMeta('ol-ExposedSettings') || []
const description = templateLinks?.find(link => link.url.split("/").pop() === category)?.description
const gotoAllLink = (category !== 'all')
return (
<div className="tagged-header-container">
<GallerySearchSortHeader
gotoAllLink={gotoAllLink}
/>
{ category && (
<>
<OLRow>
<OLCol xs={12}>
<h1 className="gallery-title">{title}</h1>
</OLCol>
</OLRow>
<OLRow>
<OLCol lg={8}>
<p className="gallery-summary">{description}</p>
</OLCol>
</OLRow>
</>
)}
</div>
)
}

View file

@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
export default function GalleryPopularTags() {
const { t } = useTranslation()
const { templateLinks } = getMeta('ol-ExposedSettings') || []
if(!templateLinks || templateLinks.length < 2) return null
return (
<div className="popular-tags">
<h1>{t('categories')}</h1>
<div className="row popular-tags-list">
{templateLinks?.filter(link => link.url.split("/").pop() !== "all").map((link, index) => (
<div key={index} className="gallery-thumbnail col-12 col-md-6 col-lg-4">
<a href={link.url}>
<div className="thumbnail-tag">
<img
src={`/img/website-redesign/gallery/${link.url.split("/").pop()}.svg`}
alt={link.name}
/>
</div>
<span className="caption-title">{link.name}</span>
</a>
<p>{link.description}</p>
</div>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,78 @@
import { useTemplateGalleryContext } from '../context/template-gallery-context'
import { useTranslation } from 'react-i18next'
import SearchForm from './search-form'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
import useSort from '../hooks/use-sort'
import withContent, { SortBtnProps } from './sort/with-content'
import MaterialIcon from '@/shared/components/material-icon'
function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
return (
<button
className="gallery-header-sort-btn inline-block"
onClick={onClick}
aria-label={screenReaderText}
>
<span>{text}</span>
{iconType ? (
<MaterialIcon type={iconType} />
) : (
<MaterialIcon type="arrow_upward" style={{ visibility: 'hidden' }} />
)}
</button>
)
}
const SortByButton = withContent(SortBtn)
export default function GallerySearchSortHeader( { gotoAllLink }: { boolean } ) {
const { t } = useTranslation()
const {
searchText,
setSearchText,
sort,
} = useTemplateGalleryContext()
const { handleSort } = useSort()
return (
<OLRow className="align-items-center">
{gotoAllLink ? (
<OLCol className="col-auto">
<a className="previous-page-link" href="/templates/all">
<i className="material-symbols material-symbols-rounded" aria-hidden="true">arrow_left_alt</i>
{t('all_templates')}
</a>
</OLCol>
) : (
<OLCol className="col-auto">
<a className="previous-page-link" href="/templates">
<i className="material-symbols material-symbols-rounded" aria-hidden="true">arrow_left_alt</i>
{t('template_gallery')}
</a>
</OLCol>
)}
<OLCol className="d-flex justify-content-center gap-2">
<SortByButton
column="lastUpdated"
text={t('last_updated')}
sort={sort}
onClick={() => handleSort('lastUpdated')}
/>
<SortByButton
column="name"
text={t('title')}
sort={sort}
onClick={() => handleSort('name')}
/>
</OLCol>
<OLCol xs={3} className="ms-auto" >
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
/>
</OLCol>
</OLRow>
)
}

View file

@ -0,0 +1,80 @@
import { useTranslation } from 'react-i18next'
export default function Pagination({ currentPage, totalPages, onPageChange }) {
const { t } = useTranslation()
if (totalPages <= 1) return null
const pageNumbers = []
let startPage = Math.max(1, currentPage - 4)
let endPage = Math.min(totalPages, currentPage + 4)
if (startPage > 1) {
pageNumbers.push(1)
if (startPage > 2) {
pageNumbers.push("...")
}
}
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i)
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pageNumbers.push("...")
}
pageNumbers.push(totalPages)
}
return (
<nav role="navigation" aria-label={t('pagination_navigation')}>
<ul className="pagination">
{/*
{currentPage > 1 && (
<li>
<button aria-label={t('go_to_first_page')} onClick={() => onPageChange(1)}>
&lt;&lt; {t('first')}
</button>
</li>
)}
*/}
{currentPage > 1 && (
<li>
<button aria-label={t('go_prev_page')} onClick={() => onPageChange(currentPage - 1)}>
&lt; {t('prev')}
</button>
</li>
)}
{pageNumbers.map((page, index) => (
<li key={index} className={page === currentPage ? "active" : ""}>
{page === "..." ? (
<span aria-hidden="true">{page}</span>
) : page === currentPage ? (
<span aria-label={t('page_current', { page })} aria-current="true">{page}</span>
) : (
<button aria-label={t('go_page', { page })} onClick={() => onPageChange(page)}>
{page}
</button>
)}
</li>
))}
{currentPage < totalPages && (
<li>
<button aria-label={t('go_next_page')} onClick={() => onPageChange(currentPage + 1)}>
{t('next')} &gt;
</button>
</li>
)}
{/*
{currentPage < totalPages && (
<li>
<button aria-label={t('go_to_last_page')} onClick={() => onPageChange(totalPages)}>
{t('last')} &gt;&gt;
</button>
</li>
)}
*/}
</ul>
</nav>
)
}

View file

@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next'
import { MergeAndOverride } from '../../../../../types/utils'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import MaterialIcon from '@/shared/components/material-icon'
type SearchFormOwnProps = {
inputValue: string
setInputValue: (input: string) => void
}
type SearchFormProps = MergeAndOverride<
React.ComponentProps<typeof OLForm>,
SearchFormOwnProps
>
export default function SearchForm({
inputValue,
setInputValue,
}: SearchFormProps) {
const { t } = useTranslation()
let placeholderMessage = t('search')
const placeholder = `${placeholderMessage}`
const handleChange: React.ComponentProps<typeof OLFormControl
>['onChange'] = e => {
setInputValue(e.target.value)
}
const handleClear = () => setInputValue('')
return (
<OLForm
role="search"
onSubmit={e => e.preventDefault()}
>
<OLFormControl
className="gallery-search-form-control"
id="gallery-search-form-control"
type="text"
value={inputValue}
onChange={handleChange}
placeholder={placeholder}
aria-label={placeholder}
prepend={<MaterialIcon type="search" />}
append={
inputValue.length > 0 && (
<button
type="button"
className="form-control-search-clear-btn"
aria-label={t('clear_search')}
onClick={handleClear}
>
<MaterialIcon type="clear" />
</button>
)
}
/>
</OLForm>
)
}

View file

@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next'
import { Sort } from '../../types/api'
type SortBtnOwnProps = {
column: string
sort: Sort
text: string
onClick: () => void
}
type WithContentProps = {
iconType?: string
screenReaderText: string
}
export type SortBtnProps = SortBtnOwnProps & WithContentProps
function withContent<T extends SortBtnOwnProps>(
WrappedComponent: React.ComponentType<T & WithContentProps>
) {
function WithContent(hocProps: T) {
const { t } = useTranslation()
const { column, text, sort } = hocProps
let iconType
let screenReaderText = t('sort_by_x', { x: text })
if (column === sort.by) {
iconType =
sort.order === 'asc' ? 'arrow_upward_alt' : 'arrow_downward_alt'
screenReaderText = t('reverse_x_sort_order', { x: text })
}
return (
<WrappedComponent
{...hocProps}
iconType={iconType}
screenReaderText={screenReaderText}
/>
)
}
return WithContent
}
export default withContent

View file

@ -0,0 +1,29 @@
import { memo } from 'react'
import { cleanHtml } from '../../../../../modules/template-gallery/app/src/CleanHtml.mjs'
function TemplateGalleryEntry({ template }) {
return (
<div className={"gallery-thumbnail col-12 col-md-6 col-lg-4"}>
<a href={`/template/${template.id}`} className="thumbnail-link">
<div className="thumbnail">
<img
src={`/template/${template.id}/preview?version=${template.version}&style=thumbnail`}
alt={template.name}
/>
</div>
<span className="gallery-list-item-title">
<span className="caption-title">{template.name}</span>
<span className="badge-container"></span>
</span>
</a>
<div className="caption">
<p className="caption-description" dangerouslySetInnerHTML={{ __html: cleanHtml(template.description, 'plainText') }} />
</div>
<div className="author-name">
<div dangerouslySetInnerHTML={{ __html: cleanHtml(template.author, 'plainText') }} />
</div>
</div>
)
}
export default memo(TemplateGalleryEntry)

View file

@ -0,0 +1,64 @@
import { TemplateGalleryProvider } from '../context/template-gallery-context'
import { useTranslation } from 'react-i18next'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
import getMeta from '@/utils/meta'
import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
import Footer from '@/features/ui/components/bootstrap-5/footer/footer'
import GalleryHeaderTagged from './gallery-header-tagged'
import GalleryHeaderAll from './gallery-header-all'
import TemplateGallery from './template-gallery'
import GallerySearchSortHeader from './gallery-search-sort-header'
import GalleryPopularTags from './gallery-popular-tags'
function TemplateGalleryRoot() {
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return (
<TemplateGalleryProvider>
<TemplateGalleryPageContent />
</TemplateGalleryProvider>
)
}
function TemplateGalleryPageContent() {
const { t } = useTranslation()
const navbarProps = getMeta('ol-navbar')
const footerProps = getMeta('ol-footer')
const category = getMeta('ol-templateCategory')
return (
<>
<DefaultNavbar {...navbarProps} />
<main id="main-content"
className={`content content-page gallery ${category ? 'gallery-tagged' : ''}`}
>
<div className="container">
{category ? (
<>
<GalleryHeaderTagged category={category} />
<TemplateGallery />
</>
) : (
<>
<GalleryHeaderAll />
<GalleryPopularTags />
<hr className="w-full border-muted mb-5" />
<div className="recent-docs">
<GallerySearchSortHeader />
<h2>{t('all_templates')}</h2>
<TemplateGallery />
</div>
</>
)}
</div>
</main>
<Footer {...footerProps} />
</>
)
}
export default withErrorBoundary(TemplateGalleryRoot, GenericErrorBoundaryFallback)

View file

@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import OLRow from '@/features/ui/components/ol/ol-row'
import { useTemplateGalleryContext } from '../context/template-gallery-context'
import TemplateGalleryEntry from './template-gallery-entry'
import Pagination from './pagination'
export default function TemplateGallery() {
const { t } = useTranslation()
const {
searchText,
sort,
visibleTemplates,
} = useTemplateGalleryContext()
const templatesPerPage = 6
const totalPages = Math.ceil(visibleTemplates.length / templatesPerPage)
const [currentPage, setCurrentPage] = useState(1)
useEffect(() => {
setCurrentPage(1)
}, [sort])
const [lastNonSearchPage, setLastNonSearchPage] = useState(1)
const [isSearching, setIsSearching] = useState(false)
useEffect(() => {
if (searchText.length > 0) {
if (!isSearching) {
setLastNonSearchPage(currentPage)
setIsSearching(true)
}
setCurrentPage(1)
} else {
if (isSearching) {
setCurrentPage(lastNonSearchPage)
setIsSearching(false)
}
}
}, [searchText])
const startIndex = (currentPage - 1) * templatesPerPage
const currentTemplates = visibleTemplates.slice(startIndex, startIndex + templatesPerPage)
return (
<>
<OLRow className="gallery-container">
{currentTemplates.length > 0 ? (
currentTemplates.map(p => (
<TemplateGalleryEntry
className="gallery-thumbnail col-12 col-md-6 col-lg-4"
key={p.id}
template={p}
/>
))
) : (
<OLRow>
<p className="text-center">{t('no_templates_found')}</p>
</OLRow>
)}
</OLRow>
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />
</>
)
}

View file

@ -0,0 +1,129 @@
import {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Template } from '../../../../../types/template'
import { GetTemplatesResponseBody, Sort } from '../types/api'
import getMeta from '../../../utils/meta'
import useAsync from '../../../shared/hooks/use-async'
import { getTemplates } from '../util/api'
import sortTemplates from '../util/sort-templates'
import { debugConsole } from '@/utils/debugging'
export type TemplateGalleryContextValue = {
visibleTemplates: Template[]
totalTemplatesCount: number
error: Error | null
sort: Sort
setSort: React.Dispatch<React.SetStateAction<Sort>>
searchText: string
setSearchText: React.Dispatch<React.SetStateAction<string>>
}
export const TemplateGalleryContext = createContext<
TemplateGalleryContextValue | undefined
>(undefined)
type TemplateGalleryProviderProps = {
children: ReactNode
}
export function TemplateGalleryProvider({ children }: TemplateGalleryProviderProps) {
const [loadedTemplates, setLoadedTemplates] = useState<Template[]>([])
const [visibleTemplates, setVisibleTemplates] = useState<Template[]>([])
const [totalTemplatesCount, setTotalTemplatesCount] = useState<number>(0)
const [sort, setSort] = useState<Sort>({
by: 'lastUpdated',
order: 'desc',
})
const prevSortRef = useRef<Sort>(sort)
const [searchText, setSearchText] = useState('')
const {
error,
runAsync,
} = useAsync<GetTemplatesResponseBody>()
const category = getMeta('ol-templateCategory') || 'all'
useEffect(() => {
runAsync(getTemplates(sort, category))
.then(data => {
setLoadedTemplates(data.templates)
setTotalTemplatesCount(data.totalSize)
})
.catch(debugConsole.error)
.finally(() => {
})
}, [runAsync])
useEffect(() => {
let filteredTemplates = [...loadedTemplates]
if (searchText.length) {
filteredTemplates = filteredTemplates.filter(template =>
template.name.toLowerCase().includes(searchText.toLowerCase()) ||
template.description.toLowerCase().includes(searchText.toLowerCase())
)
}
if (prevSortRef.current !== sort) {
filteredTemplates = sortTemplates(filteredTemplates, sort)
const loadedTemplatesSorted = sortTemplates(loadedTemplates, sort)
setLoadedTemplates(loadedTemplatesSorted)
}
setVisibleTemplates(filteredTemplates)
}, [
loadedTemplates,
searchText,
sort,
])
useEffect(() => {
prevSortRef.current = sort
}, [sort])
const value = useMemo<TemplateGalleryContextValue>(
() => ({
error,
searchText,
setSearchText,
setSort,
sort,
totalTemplatesCount,
visibleTemplates,
}),
[
error,
searchText,
setSearchText,
setSort,
sort,
totalTemplatesCount,
visibleTemplates,
]
)
return (
<TemplateGalleryContext.Provider value={value}>
{children}
</TemplateGalleryContext.Provider>
)
}
export function useTemplateGalleryContext() {
const context = useContext(TemplateGalleryContext)
if (!context) {
throw new Error(
'TemplateGalleryContext is only available inside TemplateGalleryProvider'
)
}
return context
}

View file

@ -0,0 +1,20 @@
import { useTemplateGalleryContext } from '../context/template-gallery-context'
import { Sort } from '../types/api'
import { SortingOrder } from '../../../../../types/sorting-order'
const toggleSort = (order: SortingOrder): SortingOrder => {
return order === 'asc' ? 'desc' : 'asc'
}
function useSort() {
const { sort, setSort } = useTemplateGalleryContext()
const handleSort = (by: Sort['by']) => {
setSort(prev => ({
by,
order: prev.by === by ? toggleSort(sort.order) : by === 'lastUpdated' ? 'desc' : 'asc',
}))
}
return { handleSort }
}
export default useSort

View file

@ -0,0 +1,12 @@
import { SortingOrder } from '../../../../../types/sorting-order'
import { Template } from '../../../../../types/template'
export type Sort = {
by: 'lastUpdated' | 'name'
order: SortingOrder
}
export type GetTemplatesResponseBody = {
totalSize: number
templates: Template[]
}

View file

@ -0,0 +1,12 @@
import { GetTemplatesResponseBody, Sort } from '../types/api'
import { getJSON } from '../../../infrastructure/fetch-json'
export function getTemplates(sortBy: Sort, category: string): Promise<GetTemplatesResponseBody> {
const queryParams = new URLSearchParams({
by: sortBy.by,
order: sortBy.order,
category,
}).toString()
return getJSON(`/api/templates?${queryParams}`)
}

View file

@ -0,0 +1,40 @@
import { Sort } from '../types/api'
import { Template } from '../../../../../types/template'
import { SortingOrder } from '../../../../../types/sorting-order'
import { Compare } from '../../../../../types/helpers/array/sort'
const order = (order: SortingOrder, templates: Template[]) => {
return order === 'asc' ? [...templates] : templates.reverse()
}
export const defaultComparator = (
v1: Template,
v2: Template,
key: 'name' | 'lastUpdated'
) => {
const value1 = v1[key].toLowerCase()
const value2 = v2[key].toLowerCase()
if (value1 !== value2) {
return value1 < value2 ? Compare.SORT_A_BEFORE_B : Compare.SORT_A_AFTER_B
}
return Compare.SORT_KEEP_ORDER
}
export default function sortTemplates(templates: Template[], sort: Sort) {
let sorted = [...templates]
if (sort.by === 'name') {
sorted = sorted.sort((...args) => {
return defaultComparator(...args, 'name')
})
}
if (sort.by === 'lastUpdated') {
sorted = sorted.sort((...args) => {
return defaultComparator(...args, 'lastUpdated')
})
}
return order(sort.order, sorted)
}

View file

@ -0,0 +1,48 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import OLButton from '@/features/ui/components/ol/ol-button'
import DeleteTemplateModal from './modals/delete-template-modal'
import { useTemplateContext } from '../context/template-context'
import { deleteTemplate } from '../util/api'
import type { Template } from '../../../../../types/template'
function DeleteTemplateButton() {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const { template, setTemplate } = useTemplateContext()
const handleOpenModal = () => {
setShowModal(true)
}
const handleCloseModal = () => {
if (isMounted.current) {
setShowModal(false)
}
}
const handleDeleteTemplate = async (template: Template) => {
await deleteTemplate(template)
handleCloseModal()
const previousPage = document.referrer || '/templates'
window.location.href = previousPage
}
return (
<>
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete')}
</OLButton>
<DeleteTemplateModal
template={template}
actionHandler={handleDeleteTemplate}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
export default DeleteTemplateButton

View file

@ -0,0 +1,46 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import OLButton from '@/features/ui/components/ol/ol-button'
import EditTemplateModal from './modals/edit-template-modal'
import { useTemplateContext } from '../context/template-context'
import { updateTemplate } from '../util/api'
import type { Template } from '../../../../../types/template'
export default function EditTemplateButton() {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const { template, setTemplate } = useTemplateContext()
const handleOpenModal = () => {
setShowModal(true)
}
const handleCloseModal = () => {
if (isMounted.current) {
setShowModal(false)
}
}
const handleEditTemplate = async (editedTemplate: Template) => {
const updated = await updateTemplate({ editedTemplate, template })
if (updated) {
setTemplate(prev => ({ ...prev, ...updated }))
}
}
return (
<>
<OLButton variant="secondary" onClick={handleOpenModal}>
{t('edit')}
</OLButton>
<EditTemplateModal
showModal={showModal}
handleCloseModal={handleCloseModal}
actionHandler={handleEditTemplate}
/>
</>
)
}

View file

@ -0,0 +1,17 @@
import React from 'react'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
interface FormFieldInputProps extends React.ComponentProps<typeof OLFormControl> {
value: string
placeholder?: string
onChange: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>
}
const FormFieldInput: React.FC<FormFieldInputProps> = ({
type = 'text',
...props
}) => (
<OLFormControl type={type} {...props} />
)
export default FormFieldInput

View file

@ -0,0 +1,26 @@
import React from 'react'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
interface LabeledRowFormGroupProps {
controlId: string
label: string
children: React.ReactNode
}
const LabeledRowFormGroup: React.FC<LabeledRowFormGroupProps> = ({
controlId,
label,
children,
}) => (
<OLFormGroup controlId={controlId} className="row">
<div className="col-2">
<OLFormLabel className="col-form-label col">{label}</OLFormLabel>
</div>
<div className="col-10">
{children}
</div>
</OLFormGroup>
)
export default React.memo(LabeledRowFormGroup)

View file

@ -0,0 +1,96 @@
import React, { useCallback } from 'react'
import LabeledRowFormGroup from '../form/labeled-row-form-group'
import FormFieldInput from '../form/form-field-input'
import SettingsTemplateCategory from '../settings/settings-template-category'
import SettingsLicense from '../settings/settings-license'
import SettingsLanguage from '../settings/settings-language'
import { useTranslation } from 'react-i18next'
import type { Template } from '../../../../../../types/template'
interface TemplateFormFieldsProps {
template: Partial<Template>
includeLanguage?: boolean
onChange: (changes: Partial<Template>) => void
onEnterKey?: () => void
}
function TemplateFormFields({
template,
includeLanguage = false,
onChange,
onEnterKey,
}: TemplateFormFieldsProps) {
const { t } = useTranslation()
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
onEnterKey?.()
}
},
[onEnterKey]
)
return (
<>
<LabeledRowFormGroup controlId="form-title" label={t('title') + ':'}>
<FormFieldInput
required
maxLength="255"
value={template.name ?? ''}
placeholder={t('title')}
onChange={e => onChange({ name: e.target.value })}
onKeyDown={handleKeyDown}
/>
</LabeledRowFormGroup>
<LabeledRowFormGroup controlId="form-author" label={t('author') + ':'}>
<FormFieldInput
maxLength="255"
value={template.authorMD ?? ''}
placeholder={t('author')}
onChange={e => onChange({ authorMD: e.target.value })}
onKeyDown={handleKeyDown}
/>
</LabeledRowFormGroup>
<LabeledRowFormGroup controlId="form-category" label={t('category') + ':'}>
<SettingsTemplateCategory
value={template.category}
onChange={val => onChange({ category: val })}
/>
</LabeledRowFormGroup>
<LabeledRowFormGroup controlId="form-description" label={t('description') + ':'}>
<FormFieldInput
as="textarea"
rows={8}
maxLength="5000"
value={template.descriptionMD ?? ''}
placeholder={t('description')}
onChange={e => onChange({ descriptionMD: e.target.value })}
autoFocus
/>
</LabeledRowFormGroup>
<LabeledRowFormGroup controlId="form-license" label={t('license') + ':'}>
<SettingsLicense
value={template.license}
onChange={val => onChange({ license: val })}
/>
</LabeledRowFormGroup>
{includeLanguage && (
<LabeledRowFormGroup controlId="form-language" label={t('language') + ':'}>
<SettingsLanguage
value={template.language}
onChange={val => onChange({ language: val })}
/>
</LabeledRowFormGroup>
)}
</>
)
}
export default React.memo(TemplateFormFields)

View file

@ -0,0 +1,37 @@
import React from 'react'
import withErrorBoundary from '@/infrastructure/error-boundary'
import { useProjectContext } from '@/shared/context/project-context'
import ManageTemplateModal from './manage-template-modal'
import type { Template } from '../../../../../../types/template'
interface EditorManageTemplateModalWrapperProps {
show: boolean
handleHide: () => void
openTemplate: (data: Template) => void
}
const EditorManageTemplateModalWrapper = React.memo(
function EditorManageTemplateModalWrapper({
show,
handleHide,
openTemplate,
}: EditorManageTemplateModalWrapperProps) {
const { project } = useProjectContext()
if (!project) {
// wait for useProjectContext
return null
}
return (
<ManageTemplateModal
handleHide={handleHide}
show={show}
handleAfterPublished={openTemplate}
projectId={project._id}
projectName={project.name}
/>
)
}
)
export default withErrorBoundary(EditorManageTemplateModalWrapper)

View file

@ -0,0 +1,169 @@
import React, { useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { debugConsole } from '@/utils/debugging'
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
import Notification from '@/shared/components/notification'
import {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useDetachCompileContext } from '@/shared/context/detach-compile-context'
import { useUserContext } from '@/shared/context/user-context'
import { useFocusTrap } from '../../hooks/use-focus-trap'
import TemplateFormFields from '../form/template-form-fields'
import type { Template } from '../../../../../../types/template'
interface ManageTemplateModalContentProps {
handleHide: () => void
inFlight: boolean
setInFlight: (inFlight: boolean) => void
handleAfterPublished: (data: Template) => void
projectId: string
projectName: string
}
export default function ManageTemplateModalContent({
handleHide,
inFlight,
setInFlight,
handleAfterPublished,
projectId,
projectName,
}: ManageTemplateModalContentProps) {
const { t } = useTranslation()
const { pdfFile } = useDetachCompileContext()
const user = useUserContext()
const [template, setTemplate] = useState<Partial<Template>>({
name: projectName,
authorMD: `${user.first_name} ${user.last_name}`.trim(),
})
const [override, setOverride] = useState(false)
const [titleConflict, setTitleConflict] = useState(false)
const [error, setError] = useState<string | false>(false)
const [notificationType, setNotificationType] = useState<'error' | 'warning'>('error')
const [disablePublish, setDisablePublish] = useState(false)
// Only the trimmed name gates submission
const valid = (template.name ?? '').trim()
useEffect(() => {
const queryParams = new URLSearchParams({ key: 'name', val: projectName })
getJSON(`/api/template?${queryParams}`)
.then((data) => {
if (!data) return
setTemplate(prev => ({
...prev,
descriptionMD: data.descriptionMD,
authorMD: data.authorMD,
license: data.license,
category: data.category,
}))
})
.catch(debugConsole.error)
}, [])
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!valid) return
setError(false)
setInFlight(true)
postJSON(`/template/new/${projectId}`, {
body: {
category: template.category,
name: valid,
authorMD: (template.authorMD ?? '').trim(),
license: template.license,
descriptionMD: (template.descriptionMD ?? '').trim(),
build: pdfFile.build,
override,
},
})
.then(data => {
handleHide()
handleAfterPublished(data)
})
.catch(({ response, data }) => {
if (response?.status === 409 && data.canOverride) {
setNotificationType('warning')
setOverride(true)
} else {
setNotificationType('error')
setDisablePublish(true)
}
setError(data.message)
if (response?.status === 409) setTitleConflict(true)
})
.finally(() => {
setInFlight(false)
})
}
const handleChange = (changes: Partial<Template>) => {
if ('name' in changes && titleConflict) {
setError(false)
setOverride(false)
if (disablePublish) setDisablePublish(false)
}
setTemplate(prev => ({ ...prev, ...changes }))
}
const handleEnterKey = () => {
document.getElementById('submit-publish-template')?.click()
}
const modalRef = useRef<HTMLDivElement>(null)
useFocusTrap(modalRef)
return (
<div ref={modalRef}>
<OLModalHeader closeButton>
<OLModalTitle>{t('publish_as_template')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<div className="modal-body-publish">
<div className="content-as-table">
<OLForm id="publish-template-form" onSubmit={handleSubmit}>
<TemplateFormFields
template={template}
includeLanguage={false}
onChange={handleChange}
onEnterKey={handleEnterKey}
/>
</OLForm>
</div>
</div>
{error && (
<Notification
content={error.length ? error : t('generic_something_went_wrong')}
type={notificationType}
/>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" disabled={inFlight} onClick={handleHide}>
{t('cancel')}
</OLButton>
<OLButton
id="submit-publish-template"
variant={override ? 'danger' : 'primary'}
disabled={inFlight || !valid || disablePublish}
form="publish-template-form"
type="submit"
>
{inFlight ? <>{t('publishing')}</> : override ? t('overwrite') : t('publish')}
</OLButton>
</OLModalFooter>
</div>
)
}

View file

@ -0,0 +1,52 @@
import React, { memo, useCallback, useState } from 'react'
import OLModal from '@/features/ui/components/ol/ol-modal'
import ManageTemplateModalContent from './manage-template-modal-content'
import type { Template } from '../../../../../../types/template'
interface ManageTemplateModalProps {
show: boolean
handleHide: () => void
handleAfterPublished: (data: Template) => void
projectId: string
projectName: string
}
function ManageTemplateModal({
show,
handleHide,
handleAfterPublished,
projectId,
projectName,
}: ManageTemplateModalProps) {
const [inFlight, setInFlight] = useState(false)
const onHide = useCallback(() => {
if (!inFlight) {
handleHide()
}
}, [handleHide, inFlight])
return (
<OLModal
size="lg"
animation
show={show}
onHide={onHide}
id="publish-template-modal"
// backdrop="static" will disable closing the modal by clicking
// outside of the modal element
backdrop='static'
>
<ManageTemplateModalContent
handleHide={onHide}
inFlight={inFlight}
setInFlight={setInFlight}
handleAfterPublished={handleAfterPublished}
projectId={projectId}
projectName={projectName}
/>
</OLModal>
)
}
export default memo(ManageTemplateModal)

View file

@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
import withErrorBoundary from '@/infrastructure/error-boundary'
import Notification from '@/shared/components/notification'
import TemplateActionModal from './template-action-modal'
type DeleteTemplateModalProps = Pick<
React.ComponentProps<typeof TemplateActionModal>,
'template' | 'actionHandler' | 'showModal' | 'handleCloseModal'
>
function DeleteTemplateModal({
template,
actionHandler,
showModal,
handleCloseModal,
}: DeleteTemplateModalProps) {
const { t } = useTranslation()
return (
<TemplateActionModal
action="delete"
actionHandler={actionHandler}
title={t('delete_template')}
showModal={showModal}
handleCloseModal={handleCloseModal}
template={template}
>
<p>{t('about_to_delete_template')}</p>
<ul>
<li key={`template-action-list-${template.id}`}>
<b>{template.name}</b>
</li>
</ul>
<Notification
content={t('this_action_cannot_be_undone')}
type="warning"
/>
</TemplateActionModal>
)
}
export default withErrorBoundary(DeleteTemplateModal)

View file

@ -0,0 +1,139 @@
import React, { useReducer, useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLButton from '@/features/ui/components/ol/ol-button'
import withErrorBoundary from '@/infrastructure/error-boundary'
//import { useFocusTrap } from '../../hooks/use-focus-trap'
import TemplateActionModal from './template-action-modal'
import { useTemplateContext } from '../../context/template-context'
import TemplateFormFields from '../form/template-form-fields'
import type { Template } from '../../../../../../types/template'
type EditTemplateModalProps = {
showModal: boolean
handleCloseModal: () => void
actionHandler: (editedTemplate: Template) => void | Promise<void>
}
type ActionError = {
info?: {
statusCode?: number
}
}
type TemplateFormAction =
| { type: 'UPDATE'; payload: Partial<Template> }
| { type: 'RESET'; payload: Template }
| { type: 'CLEAR_FIELD'; field: keyof Template }
function templateFormReducer(state: Template, action: TemplateFormAction): Template {
switch (action.type) {
case 'UPDATE':
return { ...state, ...action.payload }
case 'RESET':
return { ...action.payload }
case 'CLEAR_FIELD':
return { ...state, [action.field]: '' }
default:
return state
}
}
function EditTemplateModal({
showModal,
handleCloseModal,
actionHandler,
}: EditTemplateModalProps) {
const { t } = useTranslation()
const { template } = useTemplateContext()
const [editedTemplate, dispatch] = useReducer(templateFormReducer, template)
const [actionError, setActionError] = useState<ActionError | null>(null)
const clearModalErrorRef = useRef<() => void>(() => {})
useEffect(() => {
if (showModal) {
dispatch({ type: 'RESET', payload: template })
setActionError(null)
}
}, [showModal, template])
const isConflictError = useMemo(
() => actionError?.info?.statusCode === 409,
[actionError]
)
const valid = useMemo(
() => editedTemplate.name.trim().length > 0,
[editedTemplate.name]
)
const handleChange = useCallback(
(changes: Partial<Template>) => {
dispatch({ type: 'UPDATE', payload: changes })
if ('name' in changes && isConflictError) {
setActionError(null)
clearModalErrorRef.current?.()
}
},
[isConflictError]
)
const handleEnterKey = useCallback(() => {
document.getElementById('submit-edit-template')?.click()
}, [])
const handleAction = useCallback(() => {
return Promise.resolve(actionHandler(editedTemplate)).catch(err => {
setActionError(err)
throw err
})
}, [actionHandler, editedTemplate])
const submitButtonDisabled = !valid || isConflictError
return (
<TemplateActionModal
action="edit"
title={t('edit_template')}
template={editedTemplate}
showModal={showModal}
handleCloseModal={handleCloseModal}
size="lg"
actionHandler={handleAction}
renderFooterButtons={({ onConfirm, onCancel, isProcessing }) => (
<>
<OLButton variant="secondary" onClick={onCancel}>
{t('cancel')}
</OLButton>
<OLButton
id="submit-edit-template"
onClick={onConfirm}
variant="primary"
disabled={submitButtonDisabled || isProcessing}
>
{t('save')}
</OLButton>
</>
)}
onClearError={fn => {
clearModalErrorRef.current = fn
}}
>
<div className="modal-body-publish">
<div className="content-as-table">
<OLForm onSubmit={e => e.preventDefault()}>
<TemplateFormFields
template={editedTemplate}
includeLanguage
onChange={handleChange}
onEnterKey={handleEnterKey}
/>
</OLForm>
</div>
</div>
</TemplateActionModal>
)
}
export default withErrorBoundary(React.memo(EditTemplateModal))

View file

@ -0,0 +1,145 @@
import { memo, useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { getUserFacingMessage } from '@/infrastructure/fetch-json'
import * as eventTracking from '@/infrastructure/event-tracking'
import { isSmallDevice } from '@/infrastructure/event-tracking'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import Notification from '@/shared/components/notification'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import type { Template } from '../../../../../../types/template'
import { useFocusTrap } from '../../hooks/use-focus-trap'
type TemplateActionModalProps = {
title: string
size?: string
action: 'delete' | 'edit'
actionHandler: (template: Template) => Promise<void>
handleCloseModal: () => void
template: Template
showModal: boolean
children?: React.ReactNode
renderFooterButtons?: (props: {
onConfirm: () => void
onCancel: () => void
isProcessing: boolean
}) => React.ReactNode
onClearError?: (clear: () => void) => void
}
function TemplateActionModal({
title,
size,
action,
actionHandler,
handleCloseModal,
showModal,
template,
children,
renderFooterButtons,
onClearError,
}: TemplateActionModalProps) {
const { t } = useTranslation()
const [error, setError] = useState<false | { name: string; error: unknown }>(false)
const [isProcessing, setIsProcessing] = useState(false)
const isMounted = useIsMounted()
const modalRef = useRef<HTMLDivElement>(null)
useFocusTrap(modalRef, showModal)
useEffect(() => {
if (onClearError) {
onClearError(() => setError(false))
}
}, [onClearError])
async function handleActionForTemplate(template: Template) {
let errored
setIsProcessing(true)
setError(false)
try {
await actionHandler(template)
} catch (e) {
errored = { name: template.name, error: e }
}
if (isMounted.current) {
setIsProcessing(false)
}
if (!errored) {
handleCloseModal()
} else {
setError(errored)
}
}
useEffect(() => {
if (showModal) {
eventTracking.sendMB('template-info-page-interaction', {
action,
isSmallDevice,
})
} else {
setError(false)
}
}, [action, showModal])
return (
<OLModal
size={size}
show={showModal}
onHide={handleCloseModal}
id="action-tempate-modal"
backdrop="static"
>
<div ref={modalRef}>
<OLModalHeader closeButton>
<OLModalTitle>{title}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{children}
{!isProcessing && error && (
<Notification
type="error"
title={error.name}
content={getUserFacingMessage(error.error) as string}
/>
)}
</OLModalBody>
<OLModalFooter>
{renderFooterButtons ? (
renderFooterButtons({
onConfirm: () => handleActionForTemplate(template),
onCancel: handleCloseModal,
isProcessing,
})
) : (
<>
<OLButton variant="secondary" onClick={handleCloseModal}>
{t('cancel')}
</OLButton>
<OLButton
variant="danger"
onClick={() => handleActionForTemplate(template)}
disabled={isProcessing}
>
{t('confirm')}
</OLButton>
</>
)}
</OLModalFooter>
</div>
</OLModal>
)
}
export default memo(TemplateActionModal)

View file

@ -0,0 +1,42 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../../utils/meta'
import SettingsMenuSelect from './settings-menu-select'
import type { Optgroup } from './settings-menu-select'
interface SettingsLanguageProps {
value: string
onChange: (value: string) => void
}
export default function SettingsLanguage({
value,
onChange,
}: SettingsLanguageProps) {
const { t } = useTranslation()
const optgroup: Optgroup = useMemo(() => {
const options = (getMeta('ol-languages') ?? [])
// only include spell-check languages that are available in the client
.filter(language => language.dic !== undefined)
return {
label: 'Language',
options: options.map(language => ({
value: language.code,
label: language.name,
})),
}
}, [])
return (
<SettingsMenuSelect
onChange={onChange}
value={value}
options={[{ value: '', label: t('off') }]}
optgroup={optgroup}
label={t('spell_check')}
name="spellCheckLanguage"
/>
)
}

View file

@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next'
import SettingsMenuSelect from './settings-menu-select'
import type { Option } from './settings-menu-select'
export const licensesMap = {
'cc_by_4.0': 'Creative Commons CC BY 4.0',
'lppl_1.3c': 'LaTeX Project Public License 1.3c',
'other': 'Other (as stated in the work)',
}
interface SettingsLicenseProps {
value: string
onChange: (value: string) => void
}
export default function SettingsLicense({
value,
onChange,
}: SettingsLicenseProps) {
const { t } = useTranslation()
const options = Object.entries(licensesMap).map(([value, label]) => ({ value, label }))
return (
<SettingsMenuSelect
name="license"
label={t('license')}
value={value}
onChange={onChange}
options={options}
/>
)
}

Some files were not shown because too many files have changed in this diff Show more