Compare commits

...
4 Commits
Author SHA1 Message Date
Juan José Sánchez Medina 01d5002ea9 Zotero: fix: cloning projects failed with zotero (#253)
walks every project's folder tree and rewrites legacy Zotero linkedFileData to the new key names.
2026-11-04 14:47:57 +01:00
Tobias Hallmen 5b6154bcde Track-changes: read all docs for the review panel Overview and change authors (#262)
* track-changes: list ranges of all docs in the review panel Overview

GET /project/:project_id/ranges returned document-updater's
/project/:id/ranges, which only covers docs currently loaded in Redis.
Comments and tracked changes in any doc nobody had opened since it was
last unloaded (or since document-updater restarted) were missing from
the Overview tab until that file was opened.

Flush the project's loaded docs to docstore first (POST
/project/:id/flush writes them back without unloading them), then read
the ranges of all non-deleted docs from docstore. After the flush
docstore is complete and current, so there is nothing to merge; this is
simpler than overlaying document-updater's live ranges on docstore's.
The response keeps the shape the frontend decodes ([{ id, ranges }]),
and docs without ranges get {} as document-updater returned for them.

The flush makes the request cost proportional to the number of loaded
docs with unflushed changes, and docstore unarchives the project's docs
if they were archived; on very large projects opening the Overview may
therefore be slower than before.

* track-changes: flush before listing tracked change authors

getChangesUsers read docstore directly. A tracked change still in
document-updater (not yet flushed to Mongo) was not seen, so the review
panel could not name its author once they left the project. Flush the
project first, as getAllRanges does since the previous commit.

Reported in ayaka-notes/ayakaleaf-pro#108.
2026-11-04 14:12:27 +01:00
Tobias Hallmen 21165d882e Git-bridge: reject personal access tokens of suspended users (#261)
Suspended users cannot log in to web, but their git-bridge personal
access tokens kept working for clone/pull/push. Treat a token whose
owner is suspended like an unknown token in GitBridgePATManager.getUserId,
which backs both /oauth/token/info and the token checks on the
/api/v0/docs routes, so git-bridge gets a 401.
2026-11-04 14:11:24 +01:00
Tobias Hallmen a683019198 Git-bridge: nginx: only expose git-bridge project endpoints under /git/ (#260)
The /git/ location proxied every path to git-bridge, which made its
internal routes reachable from the internet: /status, /health_check,
/metrics, /diags, /api/<project>/<key>/postback and the unauthenticated
DELETE /api/projects/<project id> used by web on project expiry.

Forward only /git/<24 hex project id>[.git]/... (the git smart-HTTP
endpoints, which git-bridge protects with Oauth2Filter) and return 404
for everything else under /git/. Web talks to git-bridge directly
(GIT_BRIDGE_HOST:GIT_BRIDGE_PORT), not through nginx, so it is not
affected.
2026-11-04 14:10:09 +01:00
4 changed files with 96 additions and 6 deletions
+8 -1
View File
@@ -21,7 +21,14 @@ server {
proxy_send_timeout 10m;
}
location ^~ /git/ {
# Only the git smart-HTTP endpoints of a project (/git/<project id>[.git]/...) are
# public. git-bridge's other routes (/status, /health_check, /metrics, /diags,
# /api/...) are meant for the internal network only and must not be reachable.
location /git/ {
return 404;
}
location ~ "^/git/[0-9a-f]{24}(\.git)?(/|$)" {
resolver_timeout 2s;
resolver 127.0.0.11 valid=10s;
set $git_upstream http://${GIT_BRIDGE_HOST}:${GIT_BRIDGE_PORT};
@@ -101,14 +101,14 @@ const GitBridgePATManager = {
if (!objToken?.user_id) return null
// does the user still exists and not deleted?
// does the user still exists, not deleted and not suspended?
// tokens of a deleted user are not deleted until user expires
const user = await db.users.findOne(
{ _id: new ObjectId(objToken.user_id) },
{ projection: { _id: 1 } }
{ projection: { _id: 1, suspended: 1 } }
)
if (!user) return null
if (!user || user.suspended) return null
// non-blocking
db.oauthAccessTokens.updateOne(
@@ -37,8 +37,11 @@ const TrackChangesController = {
async getAllRanges(req, res, next) {
try {
const { project_id } = req.params
const ranges = await DocumentUpdaterHandler.promises.getProjectRanges(project_id)
res.json(ranges)
// document-updater only knows the docs loaded in Redis. Flush them to
// docstore (without unloading) and read every non-deleted doc from there.
await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
const docs = await DocstoreManager.promises.getAllRanges(project_id)
res.json(docs.map(doc => ({ id: doc._id, ranges: doc.ranges ?? {} })))
} catch (err) {
next(err)
}
@@ -50,6 +53,9 @@ const TrackChangesController = {
// provided here to assign names to authors who have left the project but still have unaccepted changes.
try {
const { project_id } = req.params
// Same as getAllRanges: a tracked change still in document-updater is
// not in docstore yet, so its author would be missing here.
await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
const memberIds = await DocstoreManager.promises.getTrackedChangesUserIds(project_id)
const limit = pLimit(3)
const users = await Promise.all(
@@ -0,0 +1,77 @@
import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js'
import { promiseMapWithLimit } from '@overleaf/promise-utils'
import { db } from './lib/mongodb.mjs'
const tags = ['server-ce', 'server-pro']
const WRITE_CONCURRENCY = parseInt(process.env.WRITE_CONCURRENCY, 10) || 10
// The Zotero module used to store linkedFileData with its own key names.
// "Zotero: fix for 6.3.0" switched to the names accepted by the
// overleaf-editor-core schema, but files imported before that still carry the
// old keys, which document-updater rejects (e.g. when cloning the project).
const LEGACY_KEYS = [
'zoteroGroupId',
'importedByUserId',
'importedByName',
'bibFormat',
]
function hasLegacyKeys(data) {
return (
data?.provider === 'zotero' && LEGACY_KEYS.some(key => key in data)
)
}
function convert(data) {
const importerId = data.importer_id ?? data.importedByUserId
const format = data.format ?? data.bibFormat
return {
provider: 'zotero',
group_id: data.group_id ?? data.zoteroGroupId ?? null,
...(data.importedAt && { importedAt: data.importedAt }),
...(importerId && { importer_id: importerId.toString() }),
importer_name: data.importer_name ?? data.importedByName ?? 'Unknown',
format: format === 'biblatex' ? 'biblatex' : 'bibtex',
}
}
function collectUpdates(folder, mongoPath, updates) {
if (!folder) return
;(folder.fileRefs || []).forEach((file, i) => {
if (hasLegacyKeys(file?.linkedFileData)) {
updates[`${mongoPath}.fileRefs.${i}.linkedFileData`] = convert(
file.linkedFileData
)
}
})
;(folder.folders || []).forEach((subfolder, i) => {
collectUpdates(subfolder, `${mongoPath}.folders.${i}`, updates)
})
}
const migrate = async () => {
await batchedUpdate(
db.projects,
{},
async nextBatch => {
await promiseMapWithLimit(WRITE_CONCURRENCY, nextBatch, async project => {
const updates = {}
;(project.rootFolder || []).forEach((folder, i) => {
collectUpdates(folder, `rootFolder.${i}`, updates)
})
if (Object.keys(updates).length === 0) return
await db.projects.updateOne({ _id: project._id }, { $set: updates })
})
},
{ _id: 1, rootFolder: 1 }
)
}
const rollback = async () => {}
export default {
tags,
migrate,
rollback,
}