diff --git a/server-ce/nginx/overleaf.conf_new b/server-ce/nginx/overleaf.conf_new new file mode 100644 index 0000000000..7d941edb4a --- /dev/null +++ b/server-ce/nginx/overleaf.conf_new @@ -0,0 +1,93 @@ +server { + listen 80; + server_name _; # Catch all, see http://nginx.org/en/docs/http/server_names.html + + root /overleaf/services/web/public/; + + # block external access to prometheus /metrics + location /metrics { + internal; + } + + location / { + proxy_pass http://127.0.0.1:4000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 10m; + proxy_send_timeout 10m; + } + + location /socket.io { + proxy_pass http://127.0.0.1:3026; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 10m; + proxy_send_timeout 10m; + } + + location /stylesheets { + expires 1y; + } + + location /minjs { + expires 1y; + } + + location /img { + expires 1y; + } + + # handle output.zip, which clsi builds on demand rather than writing to disk + location ~ ^/project/[0-9a-f]+(/user/[0-9a-f]+)?/build/[0-9a-f-]+/output/output\.zip$ { + proxy_pass http://127.0.0.1:4000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 10m; + proxy_send_timeout 10m; + } + + # handle output files for specific users + location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ { + proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf + proxy_http_version 1.1; + } + # handle output files for anonymous users + location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ { + proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf + proxy_http_version 1.1; + } + # PDF range for specific users + location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/content/([0-9a-f-]+/[0-9a-f]+)$ { + proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf + proxy_http_version 1.1; + } + # PDF range for anonymous users + location ~ ^/project/([0-9a-f]+)/content/([0-9a-f-]+/[0-9a-f]+)$ { + proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf + proxy_http_version 1.1; + } + + # block external access to metrics + location ~* ^/metrics/?$ { + return 404 'Not found'; + } + + # block external access to all health checks /health_check, /health_check/full, etc + location ~* ^/health_check { + return 404 'Not found'; + } + + # Load any extra configuration for this vhost + include /etc/nginx/vhost-extras/overleaf/*.conf; +} diff --git a/services/git-bridge/Dockerfile b/services/git-bridge/Dockerfile new file mode 100644 index 0000000000..e1309125a7 --- /dev/null +++ b/services/git-bridge/Dockerfile @@ -0,0 +1,54 @@ +# Build the a8m/envsubst binary, as it supports default values, +# which the gnu envsubst (from gettext-base) does not. +FROM golang:1.25.5-alpine AS envsubst_builder + +WORKDIR /build + +RUN go install github.com/a8m/envsubst/cmd/envsubst@latest + +FROM maven:3-amazoncorretto-21-debian AS base + +RUN apt-get update && apt-get install -y make git sqlite3 curl \ + && rm -rf /var/lib/apt/lists + +COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst +RUN chmod +x /opt/envsubst + +RUN useradd --create-home node + +FROM base AS builder + +COPY . /app + +WORKDIR /app + +RUN make package \ +# The name of the created jar contains the current version tag. +# Rename it to a static path that can be used for copying. +&& find /app/target \ + -name 'writelatex-git-bridge*jar-with-dependencies.jar' \ + -exec mv {} /git-bridge.jar \; + +FROM amazoncorretto:21-alpine + +RUN apk add --update --no-cache bash git sqlite procps htop net-tools jemalloc util-linux + +ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2 + +RUN adduser -D node + +COPY --from=builder /git-bridge.jar / + +COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst +RUN chmod +x /opt/envsubst + +COPY conf/envsubst_template.json envsubst_template.json +COPY start.sh start.sh +COPY server-pro-start.sh server-pro-start.sh + +RUN mkdir conf +RUN chown node:node conf + +USER node + +CMD ["/start.sh"] diff --git a/services/git-bridge/Makefile b/services/git-bridge/Makefile new file mode 100644 index 0000000000..3e489866c5 --- /dev/null +++ b/services/git-bridge/Makefile @@ -0,0 +1,89 @@ +# git-bridge makefile + +MVN_OPTS := --no-transfer-progress +MVN_TARGET := target/writelatex-git-bridge-1.0-SNAPSHOT-jar-with-dependencies.jar + +export BUILD_NUMBER ?= local +export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) +BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g') +export COMMIT_SHA ?= $(shell git rev-parse HEAD) +PROJECT_NAME = git-bridge + +IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) +IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) +IMAGE_REPO_BRANCH ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE) +IMAGE_REPO_MAIN ?= $(IMAGE_REPO):main +IMAGE_REPO_FINAL ?= $(IMAGE_REPO_BRANCH)-$(BUILD_NUMBER) + +runtime-conf: + /opt/envsubst < conf/envsubst_template.json > conf/runtime.json + + +run: $(MVN_TARGET) runtime-conf + @exec java $(GIT_BRIDGE_JVM_ARGS) -jar $(MVN_TARGET) conf/runtime.json + + +$(MVN_TARGET): $(shell find src -type f) pom.xml + mvn $(MVN_OPTS) package -DskipTests + +build: $(MVN_TARGET) + +docker_build_base: + docker build --pull \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from $(IMAGE_REPO_BRANCH) \ + --cache-from $(IMAGE_REPO_MAIN) \ + --tag $(IMAGE_CI) \ + --target base \ + . + +docker_build: + docker build --pull \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from $(IMAGE_REPO_BRANCH) \ + --cache-from $(IMAGE_REPO_MAIN) \ + --tag $(IMAGE_REPO_BRANCH) \ + --tag $(IMAGE_REPO_FINAL) \ + . + +format: + mvn $(MVN_OPTS) com.spotify.fmt:fmt-maven-plugin:check + +docker_format: + docker run --rm -v $(PWD):$(PWD) -w $(PWD) --user node $(IMAGE_CI) make format + +format_fix: + mvn $(MVN_OPTS) com.spotify.fmt:fmt-maven-plugin:format + + +test: + mvn $(MVN_OPTS) test + +docker_test: + docker run --rm -v $(PWD):$(PWD) -w $(PWD) --user node $(IMAGE_CI) make test + +clean: + mvn $(MVN_OPTS) clean + + +package: clean + mvn $(MVN_OPTS) package -DskipTests + +refresh_cache: refresh_cache_branch +refresh_cache_branch: + docker inspect $(IMAGE_REPO_BRANCH) > /dev/null && docker pull $(IMAGE_REPO_BRANCH) || true +refresh_cache: refresh_cache_latest +refresh_cache_latest: + docker inspect $(IMAGE_REPO_MAIN) > /dev/null && docker pull $(IMAGE_REPO_MAIN) || true + +push: + docker push $(IMAGE_REPO_FINAL) + +push_branch: + docker push $(IMAGE_REPO_BRANCH) + +clean_ci: + -docker rmi -f $(IMAGE_CI) $(IMAGE_REPO_FINAL) + -git clean -xdf . + +.PHONY: run package build clean test runtime-conf diff --git a/services/web/app/src/Features/Documents/DocumentController.mjs b/services/web/app/src/Features/Documents/DocumentController.mjs index 274e2fbad0..ceeee68813 100644 --- a/services/web/app/src/Features/Documents/DocumentController.mjs +++ b/services/web/app/src/Features/Documents/DocumentController.mjs @@ -123,7 +123,12 @@ const setDocumentSchema = z.object({ version: z.number().int(), ranges: rangesSchemas.ranges, lastUpdatedAt: z.coerce.number().int().positive().nullish(), - lastUpdatedBy: zz.objectId().nullish(), + // document-updater records the acting user of each update it applies and + // flushes it back here as lastUpdatedBy (see its RedisManager's + // updateMeta.user_id). For a link-sharing visitor with no account that + // is the sentinel 'anonymous-user', which real-time sets in Router.js -- + // never a user id. It is accepted here and normalised to null below. + lastUpdatedBy: zz.objectId().or(z.literal('anonymous-user')).nullish(), }), }) @@ -132,7 +137,11 @@ async function setDocument(req, res) { logOnly: true, }) const { Project_id: projectId, doc_id: docId } = params - const { lines, version, ranges, lastUpdatedAt, lastUpdatedBy } = body + const { lines, version, ranges, lastUpdatedAt } = body + // an anonymous editor has no user id to record on the project, and + // Project.lastUpdatedBy is an ObjectId ref -- see setDocumentSchema + const lastUpdatedBy = + body.lastUpdatedBy === 'anonymous-user' ? null : body.lastUpdatedBy const result = await ProjectEntityUpdateHandler.promises.updateDocLines( projectId, docId, diff --git a/services/web/app/src/Features/User/UserAuditLogHandler.mjs b/services/web/app/src/Features/User/UserAuditLogHandler.mjs index fcf3ebb89f..07ff0c936f 100644 --- a/services/web/app/src/Features/User/UserAuditLogHandler.mjs +++ b/services/web/app/src/Features/User/UserAuditLogHandler.mjs @@ -29,6 +29,7 @@ function _canHaveNoInitiatorId(operation, info) { if (operation === 'must-reset-password-set') return true if (operation === 'must-reset-password-unset') return true if (operation === 'account-suspension' && info.script) return true + if (operation === 'account-suspension-lifted' && info.script) return true if (operation === 'release-managed-user' && info.script) return true } diff --git a/services/web/app/src/Features/User/UserUpdater.mjs b/services/web/app/src/Features/User/UserUpdater.mjs index c23ae8793c..c4085c091e 100644 --- a/services/web/app/src/Features/User/UserUpdater.mjs +++ b/services/web/app/src/Features/User/UserUpdater.mjs @@ -633,6 +633,23 @@ async function suspendUser(userId, auditLog = {}) { ) } +async function unsuspendUser(userId, auditLog = {}) { + const res = await updateUser( + { _id: userId, suspended: true }, + { $set: { suspended: false } } + ) + if (res.matchedCount !== 1) { + throw new Errors.NotFoundError('user id not found or not suspended') + } + await UserAuditLogHandler.promises.addEntry( + userId, + 'account-suspension-lifted', + auditLog.initiatorId, + auditLog.ip, + auditLog.info || {} + ) +} + function _securityAlertPrimaryEmailChangedExtraRecipients( emailsData, oldEmail, @@ -689,6 +706,7 @@ export default { migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress), updateUser: callbackify(updateUser), suspendUser: callbackify(suspendUser), + unsuspendUser: callbackify(unsuspendUser), promises: { addAffiliationForNewUser, addEmailAddress, @@ -702,5 +720,6 @@ export default { migrateDefaultEmailAddress, updateUser, suspendUser, + unsuspendUser, }, } diff --git a/services/web/app/src/Features/User/UserUpdater.mjs.orig b/services/web/app/src/Features/User/UserUpdater.mjs.orig new file mode 100644 index 0000000000..c23ae8793c --- /dev/null +++ b/services/web/app/src/Features/User/UserUpdater.mjs.orig @@ -0,0 +1,706 @@ +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import { db } from '../../infrastructure/mongodb.mjs' +import Mongo from '../Helpers/Mongo.mjs' +import { callbackify } from 'node:util' +import UserGetter from './UserGetter.mjs' +import InstitutionsAPI from '../Institutions/InstitutionsAPI.mjs' +import Features from '../../infrastructure/Features.mjs' +import FeaturesUpdater from '../Subscription/FeaturesUpdater.mjs' +import EmailHandler from '../Email/EmailHandler.mjs' +import EmailHelper from '../Helpers/EmailHelper.mjs' +import Errors from '../Errors/Errors.js' +import UserAuditLogHandler from './UserAuditLogHandler.mjs' +import AnalyticsManager from '../Analytics/AnalyticsManager.mjs' +import EmailChangeHelper from '../Analytics/EmailChangeHelper.mjs' +import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs' +import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs' +import _ from 'lodash' +import Modules from '../../infrastructure/Modules.mjs' +import UserSessionsManager from './UserSessionsManager.mjs' +import ThirdPartyIdentityManager from './ThirdPartyIdentityManager.mjs' +import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs' + +const { normalizeQuery } = Mongo + +async function _sendSecurityAlertPrimaryEmailChanged( + userId, + oldEmail, + email, + deleteOldEmail +) { + // here + // Send email to the following: + // - the old primary + // - the new primary + // - for all other current (confirmed or recently-enough reconfirmed) email addresses, group by institution if we + // have it, or domain if we don’t, and for each group send to the most recently reconfirmed (or confirmed if never + // reconfirmed) address in that group. + // See #6101. + const emailOptions = { + actionDescribed: `the primary email address on your account was changed to ${email}`, + action: 'change of primary email address', + message: deleteOldEmail + ? [ + `We also removed the previous primary email ${oldEmail} from the account.`, + ] + : [], + } + + async function sendToRecipients(recipients) { + // On failure, log the error and carry on so that one email failing does not prevent other emails sending + for await (const recipient of recipients) { + try { + const opts = Object.assign({}, emailOptions, { to: recipient }) + await EmailHandler.promises.sendEmail('securityAlert', opts) + } catch (error) { + logger.error( + { error, userId }, + 'could not send security alert email when primary email changed' + ) + } + } + } + + // First, send notification to the old and new primary emails before getting other emails from v1 to ensure that these + // are still sent in the event of not being able to reach v1 + const oldAndNewPrimaryEmails = [oldEmail, email] + await sendToRecipients(oldAndNewPrimaryEmails) + + // Next, get extra recipients with affiliation data + const emailsData = await UserGetter.promises.getUserFullEmails(userId) + const extraRecipients = _securityAlertPrimaryEmailChangedExtraRecipients( + emailsData, + oldEmail, + email + ) + + await sendToRecipients(extraRecipients) +} + +/** + * Add a new email address for the user. Email cannot be already used by this + * or any other user + */ +async function addEmailAddress(userId, newEmail, affiliationOptions, auditLog) { + AsyncLocalStorage.removeItem('userFullEmails') + newEmail = EmailHelper.parseEmail(newEmail) + if (!newEmail) { + throw new Error('invalid email') + } + + await UserGetter.promises.ensureUniqueEmailAddress(newEmail) + + AnalyticsManager.recordEventForUserInBackground( + userId, + 'secondary-email-added' + ) + + await UserAuditLogHandler.promises.addEntry( + userId, + 'add-email', + auditLog.initiatorId, + auditLog.ipAddress, + { + ...auditLog.info, + newSecondaryEmail: newEmail, + } + ) + + try { + await InstitutionsAPI.promises.addAffiliation( + userId, + newEmail, + affiliationOptions + ) + } catch (error) { + throw OError.tag(error, 'problem adding affiliation while adding email') + } + + const createdAt = new Date() + let res + try { + const reversedHostname = newEmail.split('@')[1].split('').reverse().join('') + const update = { + $push: { + emails: { email: newEmail, createdAt, reversedHostname }, + }, + } + res = await updateUser( + { _id: userId, 'emails.email': { $ne: newEmail } }, + update + ) + } catch (error) { + throw OError.tag(error, 'problem updating users emails') + } + + if (res.matchedCount !== 1) { + return + } + + try { + await EmailChangeHelper.registerEmailCreation(userId, newEmail, { + createdAt: new Date(), + emailCreatedAt: createdAt, + }) + } catch (error) { + logger.warn( + { error, userId, newEmail }, + 'Error registering email creation with analytics' + ) + } +} + +async function clearSAMLData(userId, auditLog, sendEmail) { + const user = await UserGetter.promises.getUser(userId, { + email: 1, + emails: 1, + }) + + await UserAuditLogHandler.promises.addEntry( + userId, + 'clear-institution-sso-data', + auditLog.initiatorId, + auditLog.ipAddress, + {} + ) + + const update = { + $unset: { + samlIdentifiers: 1, + 'emails.$[].samlProviderId': 1, + 'enrollment.sso': 1, + }, + } + + await updateUser(userId, update) + + for (const emailData of user.emails) { + await InstitutionsAPI.promises.removeEntitlement(userId, emailData.email) + } + + await FeaturesUpdater.promises.refreshFeatures( + userId, + 'clear-institution-sso-data' + ) + + if (sendEmail) { + await EmailHandler.promises.sendEmail('SAMLDataCleared', { to: user.email }) + } +} + +async function clearThirdPartyIdentifiers(userId, auditLog) { + const user = await UserGetter.promises.getUser(userId, { + thirdPartyIdentifiers: 1, + }) + await UserAuditLogHandler.promises.addEntry( + userId, + 'clear-third-party-identifiers', + auditLog.initiatorId, + auditLog.ipAddress, + {} + ) + for (const thirdPartyIdentifier of user.thirdPartyIdentifiers || []) { + await ThirdPartyIdentityManager.promises.unlink( + userId, + thirdPartyIdentifier.providerId, + auditLog + ) + } +} + +/** + * set the default email address by setting the `email` attribute. The email + * must be one of the user's multiple emails (`emails` attribute) + */ +async function setDefaultEmailAddress( + userId, + email, + allowUnconfirmed, + auditLog, + sendSecurityAlert, + deleteOldEmail = false +) { + AsyncLocalStorage.removeItem('userFullEmails') + email = EmailHelper.parseEmail(email) + if (email == null) { + throw new Error('invalid email') + } + + const user = await UserGetter.promises.getUser(userId, { + email: 1, + emails: 1, + }) + if (!user) { + throw new Error('invalid userId') + } + + const oldEmail = user.email + const userEmail = user.emails.find(e => e.email === email) + if (!userEmail) { + throw new Error('Default email does not belong to user') + } + if (!userEmail.confirmedAt && !allowUnconfirmed) { + throw new Errors.UnconfirmedEmailError() + } + + await UserAuditLogHandler.promises.addEntry( + userId, + 'change-primary-email', + auditLog.initiatorId, + auditLog.ipAddress, + { + newPrimaryEmail: email, + oldPrimaryEmail: oldEmail, + } + ) + + const query = { _id: userId, 'emails.email': email } + const update = { $set: { email, lastPrimaryEmailCheck: new Date() } } + const res = await updateUser(query, update) + + // this should not happen + if (res.matchedCount !== 1) { + throw new Error('email update error') + } + + AnalyticsManager.recordEventForUserInBackground( + userId, + 'primary-email-address-updated' + ) + + try { + await EmailChangeHelper.registerEmailUpdate(userId, email, { + isPrimary: true, + action: 'updated', + createdAt: new Date(), + }) + } catch (err) { + logger.warn( + { err, userId, email }, + 'Error registering email change with analytics' + ) + } + + if (sendSecurityAlert) { + // no need to wait, errors are logged and not passed back + _sendSecurityAlertPrimaryEmailChanged( + userId, + oldEmail, + email, + deleteOldEmail + ).catch(err => { + logger.error({ err }, 'failed to send security alert email') + }) + } + + try { + await Modules.promises.hooks.fire('userEmailChanged', user, email) + } catch (err) { + logger.error( + { err, oldEmail, newEmail: email }, + 'Failed to fire "userEmailChanged" hook' + ) + } + + try { + await Modules.promises.hooks.fire( + 'updateAccountEmailAddress', + user._id, + email + ) + } catch (error) { + // errors are ignored + } +} + +/** + * Overwrites the primary email address of a user in the database in-place. + * This function is only intended for use in scripts to migrate email addresses + * where we do not want to trigger all the actions that happen when a user + * changes their own email. It should not be used in any other circumstances. + */ +async function migrateDefaultEmailAddress( + userId, + oldEmail, + newEmail, + auditLog +) { + oldEmail = EmailHelper.parseEmail(oldEmail) + if (oldEmail == null) { + throw new Error('invalid old email') + } + newEmail = EmailHelper.parseEmail(newEmail) + if (newEmail == null) { + throw new Error('invalid new email') + } + const reversedHostname = newEmail.split('@')[1].split('').reverse().join('') + const query = { + _id: userId, + email: oldEmail, + 'emails.email': oldEmail, + } + const update = { + $set: { + email: newEmail, + 'emails.$.email': newEmail, + 'emails.$.reversedHostname': reversedHostname, + }, + } + const result = await updateUser(query, update) + if (result.modifiedCount !== 1) { + throw new Error('email update error') + } + // add a user audit log entry for the email change + await UserAuditLogHandler.promises.addEntry( + userId, + 'migrate-default-email', + auditLog.initiatorId, + auditLog.ipAddress, + { + oldEmail, + newEmail, + // Add optional extra info + ...(auditLog.extraInfo || {}), + } + ) +} + +async function confirmEmail(userId, email, affiliationOptions) { + AsyncLocalStorage.removeItem('userFullEmails') + // used for initial email confirmation (non-SSO and SSO) + // also used for reconfirmation of non-SSO emails + const confirmedAt = new Date() + email = EmailHelper.parseEmail(email) + if (email == null) { + throw new Error('invalid email') + } + logger.debug({ userId, email }, 'confirming user email') + + try { + affiliationOptions = affiliationOptions || {} + affiliationOptions.confirmedAt = confirmedAt + await InstitutionsAPI.promises.addAffiliation( + userId, + email, + affiliationOptions + ) + } catch (error) { + throw OError.tag(error, 'problem adding affiliation while confirming email') + } + + const query = { + _id: userId, + 'emails.email': email, + } + + // only update confirmedAt if it was not previously set + const update = { + $set: { + 'emails.$.reconfirmedAt': confirmedAt, + }, + $min: { + 'emails.$.confirmedAt': confirmedAt, + }, + } + + if (Features.hasFeature('affiliations')) { + update.$unset = { + 'emails.$.affiliationUnchecked': 1, + } + } + + const res = await updateUser(query, update) + + if (res.matchedCount !== 1) { + throw new Errors.NotFoundError('user id and email do no match') + } + await FeaturesUpdater.promises.refreshFeatures(userId, 'confirm-email') + + try { + await EmailChangeHelper.registerEmailUpdate(userId, email, { + emailConfirmedAt: confirmedAt, + action: 'updated', + isPrimary: false, + }) + } catch (error) { + logger.warn( + { error, userId, email }, + 'Error registering email confirmation with analytics' + ) + } + + try { + await maybeCreateRedundantSubscriptionNotification(userId, email) + } catch (error) { + logger.err( + { err: error }, + 'error checking redundant subscription on email confirmation' + ) + } +} + +async function maybeCreateRedundantSubscriptionNotification(userId, email) { + const subscription = + await SubscriptionLocator.promises.getUserIndividualSubscription(userId) + if (!subscription || subscription.groupPlan) { + return + } + + const affiliations = + await InstitutionsAPI.promises.getUserAffiliations(userId) + const confirmedAffiliation = affiliations.find(a => a.email === email) + if (!confirmedAffiliation || confirmedAffiliation.licence === 'free') { + return + } + + await NotificationsBuilder.promises + .redundantPersonalSubscription( + { + institutionId: confirmedAffiliation.institution.id, + institutionName: confirmedAffiliation.institution.name, + }, + { _id: userId } + ) + .create() +} + +async function removeEmailAddress( + userId, + email, + auditLog, + skipParseEmail = false +) { + AsyncLocalStorage.removeItem('userFullEmails') + // remove one of the user's email addresses. The email cannot be the user's + // default email address + if (!skipParseEmail) { + email = EmailHelper.parseEmail(email) + } else if (skipParseEmail && typeof email !== 'string') { + throw new Error('email must be a string') + } + + if (!email) { + throw new Error('invalid email') + } + + const isMainEmail = await UserGetter.promises.getUserByMainEmail(email, { + _id: 1, + }) + if (isMainEmail) { + throw new Error('cannot remove primary email') + } + + await UserAuditLogHandler.promises.addEntry( + userId, + 'remove-email', + auditLog.initiatorId, + auditLog.ipAddress, + { + removedEmail: email, + // Add optional extra info + ...(auditLog.extraInfo || {}), + } + ) + + try { + await InstitutionsAPI.promises.removeAffiliation(userId, email) + } catch (error) { + OError.tag(error, 'problem removing affiliation') + throw error + } + + const query = { _id: userId, email: { $ne: email } } + const update = { $pull: { emails: { email } } } + + let res + try { + res = await updateUser(query, update) + } catch (error) { + OError.tag(error, 'problem removing users email') + throw error + } + + if (res.matchedCount !== 1) { + throw new Error('Cannot remove email') + } + + try { + await EmailChangeHelper.registerEmailDeletion(userId, email, { + isPrimary: false, + emailDeletedAt: new Date(), + }) + } catch (error) { + logger.warn( + { error, userId, email }, + 'Error registering email deletion with analytics' + ) + } + + await FeaturesUpdater.promises.refreshFeatures(userId, 'remove-email') +} + +async function addAffiliationForNewUser( + userId, + email, + affiliationOptions = {} +) { + AsyncLocalStorage.removeItem('userFullEmails') + await InstitutionsAPI.promises.addAffiliation( + userId, + email, + affiliationOptions + ) + try { + await updateUser( + { _id: userId, 'emails.email': email }, + { $unset: { 'emails.$.affiliationUnchecked': 1 } } + ) + } catch (error) { + logger.error( + OError.tag( + error, + 'could not remove affiliationUnchecked flag for user on create', + { + userId, + email, + } + ) + ) + } +} + +async function updateUser(query, update) { + query = normalizeQuery(query) + const result = await db.users.updateOne(query, update) + return result +} + +/** + * DEPRECATED + * + * Change the user's main email address by adding a new email, switching the + * default email and removing the old email. Prefer manipulating multiple + * emails and the default rather than calling this method directly + */ +async function changeEmailAddress(userId, newEmail, auditLog) { + newEmail = EmailHelper.parseEmail(newEmail) + if (newEmail == null) { + throw new Error('invalid email') + } + + const oldEmail = await UserGetter.promises.getUserEmail(userId) + await addEmailAddress(userId, newEmail, {}, auditLog) + await setDefaultEmailAddress(userId, newEmail, true, auditLog, true) + await removeEmailAddress(userId, oldEmail, auditLog) +} + +/** + * @param {string} userId + * @param {{initiatorId: string, ip: string}} auditLog + * @returns {Promise} + */ +async function removeReconfirmFlag(userId, auditLog) { + await UserAuditLogHandler.promises.addEntry( + userId.toString(), + 'must-reset-password-unset', + auditLog.initiatorId, + auditLog.ip + ) + await updateUser(userId.toString(), { $set: { must_reconfirm: false } }) +} + +async function suspendUser(userId, auditLog = {}) { + const res = await updateUser( + { _id: userId, suspended: { $ne: true } }, + { $set: { suspended: true } } + ) + if (res.matchedCount !== 1) { + throw new Errors.NotFoundError('user id not found or already suspended') + } + await UserAuditLogHandler.promises.addEntry( + userId, + 'account-suspension', + auditLog.initiatorId, + auditLog.ip, + auditLog.info || {} + ) + await UserSessionsManager.promises.removeSessionsFromRedis({ _id: userId }) + await Modules.promises.hooks.fire( + 'removeDropbox', + userId, + 'account-suspension' + ) +} + +function _securityAlertPrimaryEmailChangedExtraRecipients( + emailsData, + oldEmail, + email +) { + // Group by institution if we have it, or domain if we don’t, and for each group send to the most recently + // reconfirmed (or confirmed if never reconfirmed) address in that group. We also remove the original and new + // primary email addresses because they are emailed separately + // See #6101. + function sortEmailsByConfirmation(emails) { + return emails.sort((e1, e2) => e2.lastConfirmedAt - e1.lastConfirmedAt) + } + + const recipients = new Set() + const emailsToIgnore = new Set([oldEmail, email]) + + // Remove non-confirmed emails + const confirmedEmails = emailsData.filter(email => !!email.lastConfirmedAt) + + // Group other emails by institution, separating out those with no institution and grouping them instead by domain. + // The keys for each group are not used for anything other than the grouping, so can have a slightly paranoid format + // to avoid any potential clash + const groupedEmails = _.groupBy(confirmedEmails, emailData => { + if (!emailData.affiliation || !emailData.affiliation.institution) { + return `domain:${EmailHelper.getDomain(emailData.email)}` + } + return `institution_id:${emailData.affiliation.institution.id}` + }) + + // For each group of emails, order the emails by (re-)confirmation date and pick the first + for (const emails of Object.values(groupedEmails)) { + // Sort by confirmation and pick the first + sortEmailsByConfirmation(emails) + + // Ignore original and new primary email addresses + const recipient = emails[0].email + if (!emailsToIgnore.has(recipient)) { + recipients.add(emails[0].email) + } + } + + return Array.from(recipients) +} + +export default { + addAffiliationForNewUser: callbackify(addAffiliationForNewUser), + addEmailAddress: callbackify(addEmailAddress), + changeEmailAddress: callbackify(changeEmailAddress), + clearSAMLData: callbackify(clearSAMLData), + confirmEmail: callbackify(confirmEmail), + removeEmailAddress: callbackify(removeEmailAddress), + removeReconfirmFlag: callbackify(removeReconfirmFlag), + setDefaultEmailAddress: callbackify(setDefaultEmailAddress), + migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress), + updateUser: callbackify(updateUser), + suspendUser: callbackify(suspendUser), + promises: { + addAffiliationForNewUser, + addEmailAddress, + changeEmailAddress, + clearSAMLData, + clearThirdPartyIdentifiers, + confirmEmail, + removeEmailAddress, + removeReconfirmFlag, + setDefaultEmailAddress, + migrateDefaultEmailAddress, + updateUser, + suspendUser, + }, +} diff --git a/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss b/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss index 191e006ce3..94532a50d5 100644 --- a/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss +++ b/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss @@ -50,7 +50,13 @@ body { .navbar-default { position: relative; - .navbar-header .navbar-logo { + // Nudge the logo down and to the right so that it lines up with the + // sidebar items below it. Instances that set both a custom header image + // and a nav title (OVERLEAF_HEADER_IMAGE_URL and OVERLEAF_NAV_TITLE on + // Server Pro) show the title next to the logo, and the nudge would move + // only the logo and leave the two misaligned, so skip it in that case and + // keep the pair centred in the navbar as it is on every other page. + .navbar-header .navbar-brand:not(:has(.navbar-title)) .navbar-logo { @include media-breakpoint-up(md) { position: relative; top: calc(var(--spacing-05) * 2); diff --git a/services/web/modules/server-ce-scripts/scripts/export-user-projects.mjs b/services/web/modules/server-ce-scripts/scripts/export-user-projects.mjs index 4dab9d62ab..02b8f9a36d 100644 --- a/services/web/modules/server-ce-scripts/scripts/export-user-projects.mjs +++ b/services/web/modules/server-ce-scripts/scripts/export-user-projects.mjs @@ -96,7 +96,7 @@ async function exportUserProjectsToZip(userId, output) { `Exporting ${allIds.length} projects for user ${userId} to ${output}` ) - const zipStream = await createZipStreamForMultipleProjectsAsync(allIds) + const zipStream = await createZipStreamForMultipleProjectsAsync(allIds, false, null) zipStream.on('progress', progress => { updateProgress(progress.entries.total, allIds.length) @@ -132,7 +132,7 @@ async function exportSingleProject(projectId, output) { console.log('Flushing project to MongoDB...') await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(projectId) console.log(`Exporting project ${projectId} to ${output}`) - const zipStream = await createZipStreamForProjectAsync(projectId) + const zipStream = await createZipStreamForProjectAsync(projectId, false, null) await writeStreamToFileAtomically(zipStream, output) console.log('Exported project to', output) } diff --git a/services/web/scripts/suspend_users.mjs b/services/web/scripts/suspend_users.mjs new file mode 100644 index 0000000000..d39f6df614 --- /dev/null +++ b/services/web/scripts/suspend_users.mjs @@ -0,0 +1,112 @@ +/* + * Read a list of users from a file and suspend their accounts. + * + * Each line is either a user ID or an email address; the two can be mixed in + * one file. Emails are matched against any of a user's addresses, not just the + * primary one. An email that matches no account is reported as a failure and + * the rest of the file is still processed. + * + * Use the --reenable option to roll back account suspension. + * + * Usage: node scripts/suspend_users.mjs [--reenable] + */ +import fs from 'node:fs' +import { ObjectId } from '../app/src/infrastructure/mongodb.mjs' +import UserGetter from '../app/src/Features/User/UserGetter.mjs' +import UserUpdater from '../app/src/Features/User/UserUpdater.mjs' +import { promiseMapWithLimit } from '@overleaf/promise-utils' + +const ASYNC_LIMIT = 10 +const reenable = process.argv.includes('--reenable') +const action = reenable ? 're-enable' : 'suspend' +const updateUser = reenable + ? UserUpdater.promises.unsuspendUser + : UserUpdater.promises.suspendUser + +const processLogger = { + failed: [], + success: [], + printSummary: () => { + console.log( + { + success: processLogger.success, + failed: processLogger.failed, + }, + `\nDONE. ${processLogger.success.length} successful. ${processLogger.failed.length} failed to ${action}.` + ) + }, +} + +function _isEmail(identifier) { + return identifier.includes('@') +} + +function _validateIdentifierList(identifiers) { + if (!Array.isArray(identifiers)) throw new Error('users is not an array') + + identifiers.forEach(identifier => { + if (!ObjectId.isValid(identifier) && !_isEmail(identifier)) { + throw new Error(`not a valid user ID or email: ${identifier}`) + } + }) +} + +async function _resolveUserId(identifier) { + if (!_isEmail(identifier)) return identifier + + const user = await UserGetter.promises.getUserByAnyEmail(identifier, { + _id: 1, + }) + if (user == null) { + throw new Error(`no user found with email ${identifier}`) + } + return user._id +} + +async function _handleUser(identifier) { + let userId + try { + userId = await _resolveUserId(identifier) + } catch (error) { + console.log(`Failed to resolve ${identifier}`, error) + processLogger.failed.push(identifier) + return + } + + try { + await updateUser(userId, { + ip: '0.0.0.0', + info: { script: true }, + }) + } catch (error) { + console.log(`Failed to ${action} ${identifier}`, error) + processLogger.failed.push(identifier) + return + } + processLogger.success.push(identifier) +} + +async function _loopUsers(identifiers) { + return promiseMapWithLimit(ASYNC_LIMIT, identifiers, _handleUser) +} + +const fileName = process.argv.slice(2).find(arg => arg !== '--reenable') +if (!fileName) throw new Error('missing filename') +const usersFile = fs.readFileSync(fileName, 'utf8') +const identifiers = usersFile + .trim() + .split('\n') + .map(identifier => identifier.trim()) + .filter(Boolean) + +async function processUsers(identifiers) { + console.log('---Starting suspend_users script---') + _validateIdentifierList(identifiers) + console.log(`---Starting to ${action} ${identifiers.length} users---`) + await _loopUsers(identifiers) + + processLogger.printSummary() + process.exit() +} + +processUsers(identifiers) diff --git a/tools/compare_docker_images/compare.sh b/tools/compare_docker_images/compare.sh new file mode 100644 index 0000000000..3efaa17fc9 --- /dev/null +++ b/tools/compare_docker_images/compare.sh @@ -0,0 +1,22 @@ +. ./env.sh +docker history --no-trunc --format '{{.CreatedBy}}' ${OLD_VERSION} > history-old.txt +docker history --no-trunc --format '{{.CreatedBy}}' ${NEW_VERSION} > history-new.txt +diff history-old.txt history-new.txt > history-diff.txt + +rm -rf old +rm -rf new + +id=$(docker create ${OLD_VERSION}) +mkdir -p old +docker export "$id" | tar -x -C old +docker rm "$id" + +id=$(docker create ${NEW_VERSION}) +mkdir -p new +docker export "$id" | tar -x -C new +docker rm "$id" + +rm -rf delta +mkdir -p delta +rsync -a -c -m --compare-dest="$PWD/old" new/ delta/ +find delta -mindepth 1 -type d -empty -delete diff --git a/tools/compare_docker_images/env.sh b/tools/compare_docker_images/env.sh new file mode 100644 index 0000000000..bd1cc62927 --- /dev/null +++ b/tools/compare_docker_images/env.sh @@ -0,0 +1,2 @@ +export OLD_VERSION="sharelatex/sharelatex:6.3.0" +export NEW_VERSION="sharelatex/sharelatex:6.3.1"