Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export default {
AuthorizationMiddleware.ensureUserIsSiteAdmin,
UserListController.getUsersJson
)
webRouter.post('/admin/users/search',
AuthorizationMiddleware.ensureUserIsSiteAdmin,
UserListController.getUsersJsonBySearch
)
webRouter.post('/admin/projects/search',
AuthorizationMiddleware.ensureUserIsSiteAdmin,
ProjectListController.getProjectsJsonBySearch
)
webRouter.post('/admin/user/:userId/delete',
AuthorizationMiddleware.ensureUserIsSiteAdmin,
UserListController.deleteUser
Expand Down
111 changes: 103 additions & 8 deletions services/web/modules/admin-tools/app/src/ProjectListController.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,12 @@ import { fileURLToPath } from 'node:url'
import { expressify } from '@overleaf/promise-utils'
import logger from '@overleaf/logger'
import Metrics from '@overleaf/metrics'
import mongoose from 'mongoose'
import ProjectHelper from '../../../../app/src/Features/Project/ProjectHelper.mjs'
import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs'
import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs'
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs'
import { OError } from '../../../../app/src/Features/Errors/Errors.js'
import { User } from '../../../../app/src/models/User.mjs'
import { Project } from '../../../../app/src/models/Project.mjs'
import { DeletedProject } from '../../../../app/src/models/DeletedProject.mjs'
import ProjectDeleter from '../../../../app/src/Features/Project/ProjectDeleter.mjs'
import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs'

const __dirname = Path.dirname(fileURLToPath(import.meta.url))

Expand Down Expand Up @@ -64,7 +59,7 @@ async function _getProjects(
const actualProjects = await Project.find(
userId == null ? {} : { owner_ref: userId },
projection,
).lean().exec()
).limit(1000).lean().exec()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove hard 1000-project cap before filtering

This cap is applied before project filters and sorting, so when there are more than 1000 projects the list only reflects an arbitrary prefix of records; admins can miss matching deleted/trashed/inactive projects and totalSize no longer represents the real result set.

Useful? React with 👍 / 👎.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, filter in backend is deprecated.


const delProjection = Object.fromEntries(
Object.keys(projection).map(k => [`project.${k}`, 1])
Expand All @@ -75,7 +70,7 @@ async function _getProjects(
const deletedProjects = await DeletedProject.find(
userId == null ? { project: { $type: 'object' } } : { 'project.owner_ref': userId },
delProjection
).lean().exec()
).limit(1000).lean().exec()

const formattedActualProjects = _formatProjects(actualProjects, _formatProjectInfo)
const formattedDeletedProjects = _formatProjects(deletedProjects, _formatDeletedProjectInfo)
Expand All @@ -89,6 +84,98 @@ async function _getProjects(
}
}

async function _searchProjects(
userId = null,
search = '',
) {
const projection = {
_id: 1,
name: 1,
lastUpdated: 1,
lastUpdatedBy: 1,
lastOpened: 1,
trashed: 1,
owner_ref: 1,
}
const userIdObj = userId ? new mongoose.Types.ObjectId(userId) : null

const activeProjects = await Project.aggregate([
{
$lookup: {
from: 'users',
localField: 'owner_ref',
foreignField: '_id',
as: 'owner'
}
},
{ $unwind: { path: '$owner', preserveNullAndEmptyArrays: true } },
{
$match: {
$and: [
{
$or: [
{ name: { $regex: search, $options: 'i' } },
{ $expr: { $regexMatch: { input: { $toString: '$_id' }, regex: search, options: 'i' } } },
{ 'owner.email': { $regex: search, $options: 'i' } },
{ 'owner.first_name': { $regex: search, $options: 'i' } },
{ 'owner.last_name': { $regex: search, $options: 'i' } }
]
},
...(userIdObj ? [{ owner_ref: userIdObj }] : [])
]
}
},
{ $project: projection },
{ $limit: 1000 },
]).exec()

const delProjection = Object.fromEntries(
Object.keys(projection).map(k => [`project.${k}`, 1])
)
delProjection['deleterData.deletedAt'] = 1
delProjection['deleterData.deleterId'] = 1

const deletedProjects = await DeletedProject.aggregate([
{ $match: { project: { $type: 'object' } } },
{
$lookup: {
from: 'users',
localField: 'project.owner_ref',
foreignField: '_id',
as: 'owner'
}
},
{ $unwind: { path: '$owner', preserveNullAndEmptyArrays: true } },
{
$match: {
$and: [
{
$or: [
{ 'project.name': { $regex: search, $options: 'i' } },
{ $expr: { $regexMatch: { input: { $toString: '$project._id' }, regex: search, options: 'i' } } },
{ 'owner.email': { $regex: search, $options: 'i' } },
{ 'owner.first_name': { $regex: search, $options: 'i' } },
{ 'owner.last_name': { $regex: search, $options: 'i' } }
],
},
...(userIdObj ? [{ 'project.owner_ref': userIdObj }] : [{ project: { $type: 'object' } }])
]
}
},
{ $project: delProjection },
{ $limit: 1000 },
]).exec()

const formattedActiveProjects = _formatProjects(activeProjects, _formatProjectInfo)
const formattedDeletedProjects = _formatProjects(deletedProjects, _formatDeletedProjectInfo)
const formattedProjects = [...formattedActiveProjects, ...formattedDeletedProjects]

return {
totalSize: formattedProjects.length,
projects: formattedProjects,
}
}

function _formatProjects(projects, formatProjectInfo) {
const yearAgo = new Date()
yearAgo.setFullYear(yearAgo.getFullYear() - 1)
Expand Down Expand Up @@ -229,9 +316,17 @@ async function purgeDeletedProject(req, res) {
res.sendStatus(200)
}

async function getProjectsJsonBySearch(req, res) {
const { search } = req.body
const { userId } = req.body
const projects = await _searchProjects(userId, search)
res.json(projects)
}

export default {
manageProjectsPage: expressify(manageProjectsPage),
getProjectsJson: expressify(getProjectsJson),
getProjectsJsonBySearch: expressify(getProjectsJsonBySearch),
undeleteProject: expressify(undeleteProject),
purgeDeletedProject: expressify(purgeDeletedProject),
trashProjectForUser: expressify(trashProjectForUser),
Expand Down
129 changes: 128 additions & 1 deletion services/web/modules/admin-tools/app/src/UserListController.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import OwnershipTransferHandler from '../../../../app/src/Features/Collaborators
import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs'
import ErrorController from '../../../../app/src/Features/Errors/ErrorController.mjs'
import Errors, { OError } from '../../../../app/src/Features/Errors/Errors.js'
import HaveIBeenPwned from '../../../../app/src/Features/Authentication/HaveIBeenPwned.mjs'
import { db } from '../../../../app/src/infrastructure/mongodb.mjs'
import AuthenticationManager from '../../../../app/src/Features/Authentication/AuthenticationManager.mjs'

const __dirname = Path.dirname(fileURLToPath(import.meta.url))

Expand Down Expand Up @@ -212,17 +214,23 @@ async function _getUsers(
samlIdentifiers: 1,
thirdPartyIdentifiers: 1,
suspended: 1,
'features.collaborators': 1,
'features.compileTimeout': 1,
}
const projectionDeleted = {};
for (const key of Object.keys(projection)) {
projectionDeleted[key] = `$user.${key}`
}
projectionDeleted.deletedAt = '$deleterData.deletedAt'

const activeUsers = await UserGetter.promises.getUsers({}, projection)
const activeUsers = await User.find({}, projection)
.limit(1000)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove hard 1000-user cap before filtering

Applying .limit(1000) at query time truncates the admin dataset before _applyFilters/sorting run, so instances with more than 1000 users will silently hide accounts and under-report totalSize; this makes some users impossible to reach through normal list/filter workflows and breaks bulk-admin expectations in larger deployments.

Useful? React with 👍 / 👎.

.lean()

const deletedUsers = await DeletedUser.aggregate([
{ $match: { user: { $type: 'object' } } },
{ $project: projectionDeleted },
{ $limit: 1000 },
])

const allUsers = [...activeUsers, ...deletedUsers]
Expand All @@ -237,6 +245,62 @@ async function _getUsers(
}
}

async function _searchUsers(searchTerm) {
const projection = {
_id: 1,
email: 1,
first_name: 1,
last_name: 1,
lastActive: 1,
lastLoggedIn: 1,
signUpDate: 1,
loginCount: 1,
isAdmin: 1,
hashedPassword: 1,
samlIdentifiers: 1,
thirdPartyIdentifiers: 1,
suspended: 1,
'features.collaborators': 1,
'features.compileTimeout': 1,
}

const activeUsers = await User.find({
$or: [
{ email: { $regex: searchTerm, $options: 'i' } },
{ first_name: { $regex: searchTerm, $options: 'i' } },
{ last_name: { $regex: searchTerm, $options: 'i' } },
{ $expr: { $regexMatch: { input: { $toString: '$_id' }, regex: searchTerm, options: 'i' } } }
]
}, projection)
.limit(1000)
.lean()

const projectionDeleted = {};
for (const key of Object.keys(projection)) {
projectionDeleted[key] = `$user.${key}`
}
projectionDeleted.deletedAt = '$deleterData.deletedAt'

const deletedUsers = await DeletedUser.aggregate([
{ $match: { user: { $type: 'object' }, $or: [
{ 'user.email': { $regex: searchTerm, $options: 'i' } },
{ 'user.first_name': { $regex: searchTerm, $options: 'i' } },
{ 'user.last_name': { $regex: searchTerm, $options: 'i' } },
{ $expr: { $regexMatch: { input: { $toString: '$user._id' }, regex: searchTerm, options: 'i' } } }
] } },
{ $project: projectionDeleted },
{ $limit: 1000 },
])

let allUsers = [...activeUsers, ...deletedUsers]
const formattedUsers = _formatUsers(allUsers)

return {
totalSize: formattedUsers.length,
users: formattedUsers,
}
}

function _formatUsers(users) {
const formattedUsers = []
const yearAgo = new Date()
Expand Down Expand Up @@ -313,6 +377,10 @@ function _formatUserInfo(user, maxDate) {
authMethods,
allowUpdateDetails,
allowUpdateIsAdmin,
features: user.features && {
collaborators: user.features.collaborators,
compileTimeout: user.features.compileTimeout,
},
...(user.suspended && { suspended: user.suspended }),
inactive: !user.lastActive || user.lastActive < maxDate,
...(user.deletedAt && { deletedAt: user.deletedAt }),
Expand Down Expand Up @@ -543,6 +611,46 @@ async function updateUser(req, res, next) {

for (let [key, value] of Object.entries(updatesInput)) {
if (key === 'email') continue
// Update features if needed
if (key === 'features' && value && typeof value === 'object') {
const features = updatesInput.features
if (features && typeof features === 'object') {
if ('collaborators' in features && (!Number.isInteger(features.collaborators) || features.collaborators < -1)) {
return HttpErrorHandler.unprocessableEntity(req, res, 'invalid_collaborators')
}
if ('compileTimeout' in features && (!Number.isInteger(features.compileTimeout) || features.compileTimeout <= 0)) {
return HttpErrorHandler.unprocessableEntity(req, res, 'invalid_compile_timeout')
}
}
update.features = {
...(user.features ?? {}),
...value,
}
continue
}
// Update password if needed
if (key === 'password') {
// ignore empty password updates
if (value == null || value === '') {
continue
}
// validate password
if (typeof value !== 'string' || value.length < 8) {
return HttpErrorHandler.unprocessableEntity(req, res, 'Password must be at least 8 characters long')
}
let isPasswordReused
try {
isPasswordReused = await HaveIBeenPwned.promises.checkPasswordForReuse(value)
} catch (err) {
logger.warn({ err }, 'Failed to check password against HaveIBeenPwned')
}
if (isPasswordReused) {
return HttpErrorHandler.unprocessableEntity(req, res, 'Password is too common, please choose a different one')
}
const hashedPassword = await AuthenticationManager.promises.hashPassword(value)
update.hashedPassword = hashedPassword
continue
}

const newValue = typeof value === 'string' ? value.trim() : value
if (newValue === user[key]) continue
Expand Down Expand Up @@ -575,6 +683,13 @@ async function updateUser(req, res, next) {
delete update.last_name
}

// delete password from response for security reasons
if (update.password) {
delete update.password
}
if (update.hashedPassword) {
delete update.hashedPassword
}
return res.json(update)
}

Expand Down Expand Up @@ -603,9 +718,21 @@ async function getAdditionalUserInfo(req, res, next) {
res.json({ activationLink })
}

async function getUsersJsonBySearch(req, res) {
const { search } = req.body
if (typeof search !== 'string' || search.trim() === '') {
return HttpErrorHandler.unprocessableEntity(req, res, 'Search term is empty')
}

const usersPage = await _searchUsers(search.trim())
res.json(usersPage)
}


export default {
manageUsersPage: expressify(manageUsersPage),
getUsersJson: expressify(getUsersJson),
getUsersJsonBySearch: expressify(getUsersJsonBySearch),
getAdditionalUserInfo: expressify(getAdditionalUserInfo),
registerNewUser: expressify(registerNewUser),
activateAccountPage: expressify(activateAccountPage),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ export default function LoadMore() {
n: visibleProjects.length + hiddenProjectsCount,
})}
</span>{' '}
<OLButton
variant="link"
onClick={() => showAllProjects()}
className="btn-inline-link"
>
{t('show_all_projects')}
</OLButton>
{
hiddenProjectsCount <= 500 &&
<OLButton
variant="link"
onClick={() => showAllProjects()}
className="btn-inline-link"
>
{t('show_all_projects')}
</OLButton>
}
</>
) : (
<span aria-live="polite">
Expand Down
Loading