mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f2cf1c060 | ||
|
|
a99a12887c | ||
|
|
3cc6928e50 | ||
|
|
9d4ec4ff96 | ||
|
|
8e0302e685 | ||
|
|
972157f036 | ||
|
|
ad9a6ea954 | ||
|
|
3e223354fc | ||
|
|
9fd8182bd2 | ||
|
|
c7eb257a53 | ||
|
|
2657eea505 | ||
|
|
ab67680290 | ||
|
|
23b9fa3a7c | ||
|
|
cb284642c7 | ||
|
|
5b58d2dd25 |
@@ -8,3 +8,6 @@ server-ce/test
|
||||
services/web/scripts/plan-prices
|
||||
services/web/scripts/translations
|
||||
services/web/scripts/ukamf
|
||||
|
||||
# local LanguageTool ngrams data (deploy-only, not part of the image)
|
||||
tools/languagetools_server_compose
|
||||
|
||||
@@ -24,7 +24,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
&& apt-get install -y \
|
||||
unattended-upgrades \
|
||||
build-essential wget net-tools unzip time poppler-utils optipng strace nginx git python3 python-is-python3 zlib1g-dev libpcre3-dev gettext-base libwww-perl ca-certificates curl gnupg \
|
||||
qpdf \
|
||||
qpdf imagemagick ghostscript \
|
||||
# upgrade base-image, batch all the upgrades together, rather than installing them on-by-one (which is slow!)
|
||||
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
|
||||
# install Node.js https://github.com/nodesource/distributions#nodejs
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM pandoc/core:3.11-alpine
|
||||
|
||||
ENTRYPOINT []
|
||||
|
||||
RUN apk add --no-cache zip
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# replacement for quay.io/sharelatex/pdftocairo:24.02 (clsitex PDF→JPEG)
|
||||
#
|
||||
# ALIASES: pdftocairo-ol:25.12.0
|
||||
#
|
||||
# alpine:3.24 with poppler (25.12.0, >= 24.02 required by CLSI).
|
||||
#
|
||||
# Why: CLSI convertPDFToJPEG runs `pdftocairo -jpeg ...` against this image
|
||||
# for preview (794px, q90) and thumbnail (190px, q50) JPEGs of compiled PDFs.
|
||||
#
|
||||
# The SaaS image is Ubuntu-based; alpine 3.24's poppler 25.12.0 covers exactly
|
||||
# the flags CLSI uses (-jpeg -jpegopt -singlefile -scale-to-x -scale-to-y), and
|
||||
# the image is tiny.
|
||||
#
|
||||
# images/pandoc-amd64/Dockerfile is the sibling (pandoc/core + zip). Same pattern:
|
||||
# ENTRYPOINT [] because CLSI docker-runs with an explicit command (no
|
||||
# --entrypoint override).
|
||||
|
||||
FROM alpine:3.24
|
||||
|
||||
ENTRYPOINT []
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache poppler-utils \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
@@ -0,0 +1,51 @@
|
||||
# replacement for quay.io/sharelatex/png2pdf:2026-06-24 (clsitex PNG→PDF)
|
||||
#
|
||||
# Engine: img2pdf (josch) — lossless. For PNG input the IDAT streams are
|
||||
# embedded verbatim into the PDF (no re-encode); the alpha channel becomes
|
||||
# a PDF soft mask (/SMask), also lossless. That is the mechanism of the
|
||||
# whole feature: pdfTeX copies PDF image streams cheaply, while PNGs with
|
||||
# alpha/gamma/palette/interlace are re-decoded on every compile, which is
|
||||
# what made them "slow" and what fills CLSI's slow-PNG list.
|
||||
#
|
||||
# The SaaS tool is proprietary. The Go port (go/services/clsitex/png2pdf)
|
||||
# pins the exact container contract this image fulfils:
|
||||
# ENTRYPOINT + ["--in-place", "--", "<relpath>", ...] CWD=/compile
|
||||
# stdout: one "Converted <f> to PDF" line per file (counted by Go)
|
||||
# exit non-zero on first failure (CLSI falls back to the original PNGs)
|
||||
#
|
||||
# Why git@commit instead of `pip install img2pdf==0.6.3`:
|
||||
# 0.6.3 (2025-11-06) is the last PyPI release, and it PRE-DATES the
|
||||
# parse_png quadratic-rewrite fix (2025-11-16, "my 311MB ARCHD at 600dpi
|
||||
# with 17638 IDAT records went from many minutes to 320ms"). The slow-PNG
|
||||
# list is populated by BIG PNGs (min 1MB, no upper bound), so we pin main
|
||||
# at this commit: it is still reproducible (exact hash) and carries the
|
||||
# fix plus the JPEG2000/ICC and pikepdf-writer-close follow-ups.
|
||||
#
|
||||
# pikepdf + pillow have musllinux cp314 wheels for this python version, so
|
||||
# no compiler is needed; git is needed only for the pinned checkout.
|
||||
|
||||
FROM python:3.14-alpine
|
||||
|
||||
ARG IMG2PDF_REF=0fafa2c312be50c68a07e2331a89bf4adbd274ed
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache git \
|
||||
&& pip install --no-cache-dir --disable-pip-version-check \
|
||||
"git+https://gitlab.mister-muffin.de/josch/img2pdf.git@${IMG2PDF_REF}#egg=img2pdf" \
|
||||
pikepdf==10.15.0 \
|
||||
pillow==12.3.0 \
|
||||
packaging \
|
||||
&& rm -rf /root/.cache/pip /usr/src/python/lib*
|
||||
|
||||
# shim: arg parsing, leading-dash path handling (paths derive from URL
|
||||
# paths and start with '-'), temp-file + atomic replace (input must survive
|
||||
# a failed conversion — CLSI renames the untouched original as fallback),
|
||||
# and the "Converted <f> to PDF" stdout line Go counts.
|
||||
COPY server-ce/png2pdf/png2pdf.sh /usr/local/bin/png2pdf
|
||||
RUN chmod 0755 /usr/local/bin/png2pdf
|
||||
|
||||
# The Go runner passes the arg list ["--in-place", "--", ...] as the container
|
||||
# command, never a binary name — see the pdftocairo image (ENTRYPOINT []) for
|
||||
# the contrast: that image provides the binary NAME in its command, this one
|
||||
# relies on the image's own ENTRYPOINT.
|
||||
ENTRYPOINT ["/usr/local/bin/png2pdf"]
|
||||
+58
-4
@@ -11,20 +11,33 @@ export OVERLEAF_BASE_TAG ?= sharelatex/sharelatex-base:$(BRANCH_NAME_TAG_SAFE)-$
|
||||
export OVERLEAF_BRANCH ?= sharelatex/sharelatex:$(BRANCH_NAME_TAG_SAFE)
|
||||
export OVERLEAF_LATEST ?= sharelatex/sharelatex
|
||||
export OVERLEAF_TAG ?= sharelatex/sharelatex:$(BRANCH_NAME_TAG_SAFE)-$(MONOREPO_REVISION)
|
||||
export OVERLEAF_PANDOC_BRANCH ?= sharelatex/sharelatex-pandoc:$(BRANCH_NAME_TAG_SAFE)
|
||||
export OVERLEAF_PANDOC_LATEST ?= sharelatex/sharelatex-pandoc
|
||||
export OVERLEAF_PANDOC_TAG ?= sharelatex/sharelatex-pandoc:$(BRANCH_NAME_TAG_SAFE)-$(MONOREPO_REVISION)
|
||||
export OVERLEAF_PDFTOCAIRO ?= sharelatex/pdftocairo:24.02
|
||||
export OVERLEAF_PNG2PDF ?= sharelatex/png2pdf:2026-06-24
|
||||
|
||||
all: build-base build-community
|
||||
|
||||
all: build-base build-community build-pandoc build-pdftocairo build-png2pdf
|
||||
|
||||
clean:
|
||||
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG)
|
||||
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG) $(OVERLEAF_PANDOC_TAG) $(OVERLEAF_PDFTOCAIRO) $(OVERLEAF_PNG2PDF)
|
||||
|
||||
refresh-cache: refresh-cache-branch
|
||||
refresh-cache-branch:
|
||||
docker inspect $(OVERLEAF_BASE_BRANCH) > /dev/null && docker pull $(OVERLEAF_BASE_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_BRANCH) > /dev/null && docker pull $(OVERLEAF_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_PANDOC_BRANCH) > /dev/null && docker pull $(OVERLEAF_PANDOC_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_PDFTOCAIRO) > /dev/null && docker pull $(OVERLEAF_PDFTOCAIRO) || true
|
||||
docker inspect $(OVERLEAF_PNG2PDF) > /dev/null && docker pull $(OVERLEAF_PNG2PDF) || true
|
||||
|
||||
refresh-cache: refresh-cache-latest
|
||||
refresh-cache-latest:
|
||||
docker inspect $(OVERLEAF_BASE_LATEST) > /dev/null && docker pull $(OVERLEAF_BASE_LATEST) || true
|
||||
docker inspect $(OVERLEAF_LATEST) > /dev/null && docker pull $(OVERLEAF_LATEST) || true
|
||||
docker inspect $(OVERLEAF_PANDOC_LATEST) > /dev/null && docker pull $(OVERLEAF_PANDOC_LATEST) || true
|
||||
docker inspect $(OVERLEAF_PDFTOCAIRO) > /dev/null && docker pull $(OVERLEAF_PDFTOCAIRO) || true
|
||||
docker inspect $(OVERLEAF_PNG2PDF) > /dev/null && docker pull $(OVERLEAF_PNG2PDF) || true
|
||||
|
||||
build-base:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
@@ -39,7 +52,6 @@ build-base:
|
||||
--tag $(OVERLEAF_BASE_BRANCH) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
|
||||
build-community:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
@@ -54,11 +66,53 @@ build-community:
|
||||
--tag $(OVERLEAF_BRANCH) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
build-pandoc:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--progress=plain \
|
||||
--build-arg OVERLEAF_BASE_TAG \
|
||||
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
|
||||
--cache-from $(OVERLEAF_PANDOC_LATEST) \
|
||||
--cache-from $(OVERLEAF_PANDOC_BRANCH) \
|
||||
--file Dockerfile-pandoc \
|
||||
--tag $(OVERLEAF_PANDOC_TAG) \
|
||||
--tag $(OVERLEAF_PANDOC_BRANCH) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
build-pdftocairo:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--progress=plain \
|
||||
--build-arg OVERLEAF_BASE_TAG \
|
||||
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
|
||||
--cache-from $(OVERLEAF_PDFTOCAIRO) \
|
||||
--file Dockerfile-pdftocairo \
|
||||
--tag $(OVERLEAF_PDFTOCAIRO) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
build-png2pdf:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--progress=plain \
|
||||
--build-arg OVERLEAF_BASE_TAG \
|
||||
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
|
||||
--cache-from $(OVERLEAF_PNG2PDF) \
|
||||
--file Dockerfile-png2pdf \
|
||||
--tag $(OVERLEAF_PNG2PDF) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
push:
|
||||
docker push $(OVERLEAF_BASE_TAG)
|
||||
docker push $(OVERLEAF_BASE_BRANCH)
|
||||
docker push $(OVERLEAF_TAG)
|
||||
docker push $(OVERLEAF_BRANCH)
|
||||
docker push $(OVERLEAF_PANDOC_TAG)
|
||||
docker push $(OVERLEAF_PANDOC_BRANCH)
|
||||
docker push $(OVERLEAF_PDFTOCAIRO)
|
||||
docker push $(OVERLEAF_PNG2PDF)
|
||||
|
||||
SHELLCHECK_OPTS = \
|
||||
--shell=bash \
|
||||
@@ -80,5 +134,5 @@ shellcheck_fix:
|
||||
done
|
||||
|
||||
.PHONY: all \
|
||||
build-base build-community \
|
||||
build-base build-community build-pandoc build-pdftocairo build-png2pdf\
|
||||
shellcheck shellcheck_fix
|
||||
|
||||
@@ -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};
|
||||
@@ -63,6 +70,17 @@ server {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/bin/sh
|
||||
# CLSI "png2pdf" container contract shim (engine: img2pdf 0.6.3, josch).
|
||||
# Replaces the proprietary quay.io/sharelatex/png2pdf:2026-06-24.
|
||||
#
|
||||
# Invoked by the CLSI docker runner (go/services/clsitex/dockerrunner.go, go/services/clsitex/png2pdf/png2pdf.go):
|
||||
# args: ["--in-place", "--", "<relpath1>", ...] CWD=/compile
|
||||
# the docker runner does NOT supply a binary name in the command, so the
|
||||
# image ENTRYPOINT is this script.
|
||||
#
|
||||
# Paths: each arg is a conversion path CLSI downloaded, RELATIVE to the
|
||||
# mounted cache dir (CWD /compile). Real CLSI derives it from the file
|
||||
# URL path (u.Path: '/'->'-') + mtime + ".opt<10-hex cacheKey>",
|
||||
# e.g. ".-uploads-filestore-a1b2c3d4-0-1-1756600000000.opta1b2c3d4a1bc"
|
||||
# (leading dash, no directory separator).
|
||||
#
|
||||
# "IN-PLACE" means "rewrite each input PATH as a PDF". CLSI then renames
|
||||
# each path to its own ".opt" cache entry and copies it into the compile
|
||||
# dir (urlcache.CommitConversion) — PER FILE and UNCONDITIONALLY (sync.go:
|
||||
# a conversion error only logs a warning). So each converted file lands
|
||||
# in the cache even in a partially-failed run; an unconverted file is
|
||||
# committed as the original-PNG fallback.
|
||||
#
|
||||
# Contract (go/services/clsitex/png2pdf/png2pdf.go):
|
||||
# - stdout: one "Converted <f> to PDF" per converted file (Go counts
|
||||
# lines matching ^Converted .+ to PDF$ -> stats.png2pdf).
|
||||
# - exit non-zero if anything fails: CLSI logs a warning and commits
|
||||
# each file's current on-disk state (converted or original).
|
||||
#
|
||||
# Why img2pdf: LOSSLESS. PNG IDAT is embedded verbatim into the PDF (no
|
||||
# re-encode); the alpha channel becomes a PDF /SMask, also lossless.
|
||||
# That is the mechanism of the feature: pdfTeX copies PDF image streams
|
||||
# cheaply, whereas PNGs with alpha/gamma/palette/interlace are re-decoded
|
||||
# from the PNG on every compile — what made them "slow". Without
|
||||
# --imgsize the image keeps its pHYs DPI, falling back to 96dpi (the same
|
||||
# default pdfLaTeX itself uses).
|
||||
#
|
||||
# Failure handling: convert to a temp + atomic replace, and CONTINUE with
|
||||
# the remaining files after a failure (their valid conversions are
|
||||
# committed by CLSI even though the run is reported failed). A failed
|
||||
# file stays exactly the original PNG CLSI downloaded.
|
||||
|
||||
set -u
|
||||
|
||||
# argv is exactly [--in-place]... ["--"] <files...>. The loop terminates:
|
||||
# every iteration either shifts, breaks, or exits.
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--) shift ; break ;;
|
||||
--in-place) shift ;;
|
||||
*) echo "png2pdf: unexpected argument: $1" >&2 ; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
echo "png2pdf: no input files (usage: --in-place -- <png>...)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
rc=0
|
||||
for file in "$@"; do
|
||||
# Resolve against CWD so a leading '-' (or '.') never lands in an argv
|
||||
# slot of img2pdf/mv, where it would be parsed as an option.
|
||||
case "$file" in
|
||||
/ | /*) abs="$file" ;;
|
||||
*) abs="$(pwd)/$file" ;;
|
||||
esac
|
||||
tmp="${abs}.tmp"
|
||||
|
||||
if ! img2pdf --output "$tmp" "$abs"; then
|
||||
echo "png2pdf: conversion failed for $file" >&2
|
||||
rm -f -- "$tmp"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
if ! mv -- "$tmp" "$abs"; then
|
||||
echo "png2pdf: failed to replace $file" >&2
|
||||
rm -f -- "$tmp"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
echo "Converted $file to PDF"
|
||||
done
|
||||
exit "$rc"
|
||||
@@ -1,5 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Grant the service user access to the Docker socket group, same as the CLSI
|
||||
# run script. Needed for CONVERTER=pdftocairo-sandbox (filestore launches the
|
||||
# sibling conversion container through /var/run/docker.sock). Idempotent: a
|
||||
# fresh container may have no such group yet, and CLSI's run script does the
|
||||
# same thing, so just append to the membership if it exists.
|
||||
if [ -e '/var/run/docker.sock' ]; then
|
||||
DOCKER_GROUP=$(stat -c '%g' /var/run/docker.sock)
|
||||
if ! getent group dockeronhost >/dev/null; then
|
||||
groupadd --non-unique --gid "${DOCKER_GROUP}" dockeronhost
|
||||
fi
|
||||
usermod -aG dockeronhost www-data
|
||||
fi
|
||||
|
||||
source /etc/overleaf/env.sh
|
||||
export LISTEN_ADDRESS=127.0.0.1
|
||||
|
||||
|
||||
@@ -48,7 +48,20 @@ const DockerRunner = {
|
||||
image = `${Settings.texliveImageNameOveride}/${img}`
|
||||
}
|
||||
|
||||
if (compileGroup === 'synctex-output') {
|
||||
if (
|
||||
Settings.path.sandboxedCompilesHostDirCache &&
|
||||
directory.startsWith(Settings.path.clsiCacheDir + Path.sep)
|
||||
) {
|
||||
// In: directory = '<clsiCacheDir>/projectId'
|
||||
// sandboxedCompilesHostDirCache = '/host/cache'
|
||||
// Out: directory = '/host/cache/projectId'
|
||||
// (png2pdf & friends operate on CLSI's per-project cache dir, which
|
||||
// is not under the compiles dir)
|
||||
directory = Path.join(
|
||||
Settings.path.sandboxedCompilesHostDirCache,
|
||||
Path.basename(directory)
|
||||
)
|
||||
} else if (compileGroup === 'synctex-output') {
|
||||
// In: directory = '/overleaf/services/clsi/output/projectId-userId/generated-files/buildId'
|
||||
// directory.split('/').slice(-3) === 'projectId-userId/generated-files/buildId'
|
||||
// sandboxedCompilesHostDirOutput = '/host/output'
|
||||
@@ -93,32 +106,35 @@ const DockerRunner = {
|
||||
// logOptions = _.clone(options)
|
||||
// logOptions?.HostConfig?.SecurityOpt = "secomp used, removed in logging"
|
||||
logger.debug({ projectId }, 'running docker container')
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
(error, output) => {
|
||||
if (error && error.statusCode === 500) {
|
||||
logger.debug(
|
||||
{ err: error, projectId },
|
||||
'error running container so destroying and retrying'
|
||||
)
|
||||
DockerRunner.destroyContainer(name, null, true, error => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
callback
|
||||
// Bounded retry: a permanent container create/start failure (e.g.
|
||||
// "unable to find user" on a FROM-scratch image) must not busy-loop
|
||||
// forever — surface the error to the caller after a couple of attempts.
|
||||
let retriesLeft = 2
|
||||
const runAttempt = () => {
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
(error, output) => {
|
||||
if (error && error.statusCode === 500 && retriesLeft > 0) {
|
||||
retriesLeft -= 1
|
||||
logger.debug(
|
||||
{ err: error, projectId, retriesLeft },
|
||||
'error running container so destroying and retrying'
|
||||
)
|
||||
})
|
||||
} else {
|
||||
callback(error, output)
|
||||
DockerRunner.destroyContainer(name, null, true, err => {
|
||||
if (err != null) {
|
||||
return callback(err)
|
||||
}
|
||||
runAttempt()
|
||||
})
|
||||
} else {
|
||||
callback(error, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
runAttempt()
|
||||
|
||||
// pass back the container name to allow it to be killed
|
||||
return name
|
||||
|
||||
@@ -172,7 +172,15 @@ if ((process.env.DOCKER_RUNNER || process.env.SANDBOXED_COMPILES) === 'true') {
|
||||
wordcount: { 'HostConfig.AutoRemove': true },
|
||||
synctex: { 'HostConfig.AutoRemove': true },
|
||||
'synctex-output': { 'HostConfig.AutoRemove': true },
|
||||
conversions: { 'HostConfig.AutoRemove': true },
|
||||
// The pdftocairo image is also built FROM scratch with no passwd file,
|
||||
// so the default named user (www-data) fails at container create with
|
||||
// "unable to find user ... no matching entries in passwd file". Run it
|
||||
// as the numeric uid/gid of this clsi process (which owns the files it
|
||||
// converts), same as the png2pdf image below.
|
||||
conversions: {
|
||||
'HostConfig.AutoRemove': true,
|
||||
User: `${process.getuid()}:${process.getgid()}`,
|
||||
},
|
||||
// The png2pdf image is built FROM scratch and has no 'tex' user, so run
|
||||
// it as the same uid/gid as this clsi process, which owns the cache files
|
||||
// it converts in place.
|
||||
|
||||
@@ -2,6 +2,7 @@ import metrics from '@overleaf/metrics'
|
||||
import Settings from '@overleaf/settings'
|
||||
import { callbackify } from 'node:util'
|
||||
import SafeExec from './SafeExec.js'
|
||||
import SandboxConverterRunner from './SandboxConverterRunner.js'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const { ConversionError } = Errors
|
||||
@@ -22,6 +23,9 @@ export default {
|
||||
}
|
||||
|
||||
async function convert(sourcePath, requestedFormat) {
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, requestedFormat, 1500)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
const width = 1500
|
||||
return await _convert(sourcePath, requestedFormat, [
|
||||
@@ -49,6 +53,9 @@ async function convert(sourcePath, requestedFormat) {
|
||||
}
|
||||
|
||||
async function thumbnail(sourcePath) {
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, 'png', 700)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
const width = 700
|
||||
return await _convert(sourcePath, 'png', [
|
||||
@@ -81,6 +88,9 @@ async function thumbnail(sourcePath) {
|
||||
|
||||
async function preview(sourcePath) {
|
||||
const width = 1000
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, 'png', width)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
return await _convert(sourcePath, 'png', [
|
||||
'pdftocairo',
|
||||
@@ -109,6 +119,50 @@ async function preview(sourcePath) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pdftocairo conversion inside a sandboxed sibling container (no
|
||||
* network, all caps dropped, memory cap — see SandboxConverterRunner.js).
|
||||
*
|
||||
* Keeps the same contract as the local path: the result is written to
|
||||
* `${sourcePath}.${requestedFormat}`.
|
||||
*
|
||||
* @param {string} sourcePath
|
||||
* @param {string} requestedFormat 'png' or 'jpg'
|
||||
* @param {number} width output width in px
|
||||
* @return {Promise<{outputHostPath: string}>}
|
||||
*/
|
||||
async function _convertInSandbox(sourcePath, requestedFormat, width) {
|
||||
if (!APPROVED_FORMATS.includes(requestedFormat)) {
|
||||
throw new ConversionError('invalid format requested', { format: requestedFormat })
|
||||
}
|
||||
const ext = requestedFormat
|
||||
const workDir = '/work'
|
||||
// poppler appends the device extension to the output argument, so the
|
||||
// argument must end WITHOUT an extension; the result lands at
|
||||
// /work/input.pdf.<png|jpg> next to the input.
|
||||
const device = requestedFormat === 'png' ? '-png' : '-jpeg'
|
||||
const result = await SandboxConverterRunner.runConversion({
|
||||
sourcePath,
|
||||
containerArgs: [
|
||||
'pdftocairo',
|
||||
device,
|
||||
'-singlefile',
|
||||
'-scale-to-x',
|
||||
width.toString(),
|
||||
'-scale-to-y',
|
||||
'-1', // maintain aspect ratio
|
||||
`${workDir}/input.pdf`,
|
||||
`${workDir}/input.pdf`,
|
||||
],
|
||||
workDir,
|
||||
outputBase: 'input.pdf',
|
||||
outputExt: ext,
|
||||
destPath: `${sourcePath}.${requestedFormat}`,
|
||||
})
|
||||
// Same contract as the local converters: return the destination path string.
|
||||
return result.outputHostPath
|
||||
}
|
||||
|
||||
async function _convert(sourcePath, requestedFormat, command) {
|
||||
if (!APPROVED_FORMATS.includes(requestedFormat)) {
|
||||
throw new ConversionError('invalid format requested', {
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
// SandboxConverterRunner.js
|
||||
//
|
||||
// Runs a PDF→image conversion (pdftocairo) in a throwaway sibling container,
|
||||
// the same isolation profile CLSI's DockerRunner gives its conversion
|
||||
// containers (see services/clsi/app/js/DockerRunner.mjs and the `conversions`
|
||||
// / `png2pdf` compile groups in services/clsi/config/settings.defaults.cjs):
|
||||
//
|
||||
// - no network access (HostConfig.NetworkMode: 'none')
|
||||
// - all capabilities dropped (HostConfig.CapDrop: ['ALL'])
|
||||
// - no new privileges (SecurityOpt: 'no-new-privileges')
|
||||
// - hard memory cap
|
||||
// - numeric uid:gid of the overleaf service user (the scratch-based
|
||||
// converter images have no passwd entries, so named users fail)
|
||||
// - container is always removed afterwards
|
||||
//
|
||||
// Implemented as a minimal Docker Engine API client over the unix socket so
|
||||
// that no new npm dependency is required (this service is installed with
|
||||
// Yarn PnP; cross-service packages such as dockerode are not resolvable
|
||||
// here).
|
||||
//
|
||||
// IMPORTANT (bind mounts): the daemon runs on the Docker host, so the
|
||||
// bind source MUST be a daemon-host-visible path (in this deployment:
|
||||
// under the overleaf `./data` volume), never a container-internal path
|
||||
// such as /tmp. The caller passes a `hostDir` that satisfies this.
|
||||
|
||||
import http from 'node:http'
|
||||
import Path from 'node:path'
|
||||
import Fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const { ConversionError } = Errors
|
||||
|
||||
const API_VERSION = 'v1.43' // conservative; the engine API is backwards compatible
|
||||
|
||||
/**
|
||||
* One request against the Docker Engine API on a unix socket.
|
||||
*
|
||||
* @param {string} socketPath
|
||||
* @param {string} method
|
||||
* @param {string} apiPath e.g. `/v1.43/containers/create?name=xyz`
|
||||
* @param {object} [opts]
|
||||
* @param {object|string} [opts.body] JSON body
|
||||
* @param {number} [opts.timeoutMs] hard deadline for the whole request
|
||||
* @param {Buffer|null} [opts.expectRaw] pass a truthy value to keep the body
|
||||
* as a Buffer (used for the log stream)
|
||||
* @return {Promise<{status: number, headers: object, body: any, raw: Buffer}>}
|
||||
*/
|
||||
function request(socketPath, method, apiPath, { body, timeoutMs = 60000, raw = false } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body === undefined || body === null ? null : JSON.stringify(body)
|
||||
let settled = false
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath,
|
||||
method,
|
||||
path: apiPath,
|
||||
headers: data
|
||||
? {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(data),
|
||||
}
|
||||
: {},
|
||||
},
|
||||
(res) => {
|
||||
const chunks = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
const buf = Buffer.concat(chunks)
|
||||
if (raw) return resolve({ status: res.statusCode, headers: res.headers, body: buf, raw: buf })
|
||||
let parsed = buf
|
||||
if (buf.length && (buf[0] === 0x7b || buf[0] === 0x5b)) {
|
||||
try {
|
||||
parsed = JSON.parse(buf.toString('utf8'))
|
||||
} catch (e) {
|
||||
// non-JSON body (e.g. plain text) — keep buffer
|
||||
parsed = buf
|
||||
}
|
||||
}
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: parsed, raw: buf })
|
||||
})
|
||||
}
|
||||
)
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
req.destroy()
|
||||
reject(new Error(`docker api ${method} ${apiPath} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
timer.unref?.()
|
||||
req.on('error', (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
if (data) req.write(data)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** Decode the framed (TTY=false) docker log stream. */
|
||||
function decodeLogStream(buf) {
|
||||
if (!buf || !buf.length) return ''
|
||||
// 8-byte header per frame: [stream(1)] [size_be32(4)] [reserved(3)] + payload
|
||||
// (older daemons use a 4-byte header; try the 8-byte layout first)
|
||||
let out = ''
|
||||
let i = 0
|
||||
while (i + 8 <= buf.length) {
|
||||
const size = buf.readUInt32BE(i + 4)
|
||||
if (i + 8 + size > buf.length) break
|
||||
if (buf[i] === 1 || buf[i] === 2) out += buf.subarray(i + 8, i + 8 + size).toString('utf8')
|
||||
i += 8 + size
|
||||
}
|
||||
return out || buf.toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort sweep of leftover `filestore-convert-*` containers from crashes
|
||||
* or service restarts mid-run (a container that was created but never started
|
||||
* or removed otherwise would leak forever).
|
||||
*/
|
||||
async function _sweepLeftovers(socketPath) {
|
||||
let res
|
||||
try {
|
||||
res = await request(
|
||||
socketPath,
|
||||
'GET',
|
||||
`/${API_VERSION}/containers/json?all=1&limit=200&filters=%7B%22name%22%3A%5B%22filestore-convert-%22%5D%7D`,
|
||||
{ timeoutMs: 10000 }
|
||||
)
|
||||
} catch (err) {
|
||||
return // not fatal
|
||||
}
|
||||
if (res.status !== 200 || !Array.isArray(res.body)) return
|
||||
for (const c of res.body) {
|
||||
if (typeof c.Names?.[0] === 'string' && c.Names[0].startsWith('/filestore-convert-')) {
|
||||
const name = c.Names[0].slice(1)
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
await request(socketPath, 'DELETE', `/${API_VERSION}/containers/${name}?force=1`, { timeoutMs: 10000 })
|
||||
logger.warn({ name }, 'sandbox conversion: removed leftover container')
|
||||
break
|
||||
} catch (err) {
|
||||
if (attempt === 0) continue
|
||||
logger.warn({ err, name }, 'sandbox conversion: failed to remove leftover container')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a conversion in a sandboxed container.
|
||||
*
|
||||
* Layout contract (mirrors CLSI's conversion runner):
|
||||
* host: <hostDir>/<runId>/input.pdf <- the source file (copied in)
|
||||
* host: <hostDir>/<runId>/<outputBase>.<ext> <- produced by the container
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.sourcePath absolute path to the source PDF (must be
|
||||
* inside a daemon-host-visible directory — the bind source is derived from it)
|
||||
* @param {string} opts.containerArgs full argv inside the container, e.g.
|
||||
* ['pdftocairo','-png','-singlefile','-scale-to-x','700','-scale-to-y','-1',
|
||||
* '/work/input.pdf','/work/output']
|
||||
* @param {string} opts.workDir basename mapping used in `containerArgs`; the
|
||||
* source file is placed at <workDir>/input.pdf inside the container
|
||||
* @param {string} opts.outputBase container-side output basename (without ext)
|
||||
* @param {string} opts.outputExt 'png' or 'jpg'
|
||||
* @param {string} opts.destPath absolute host path where the result must end up
|
||||
* @return {Promise<{container: string, exitCode: number, outputHostPath: string, durationMs: number}>}
|
||||
*/
|
||||
export async function runConversion(opts) {
|
||||
const {
|
||||
sourcePath,
|
||||
containerArgs,
|
||||
workDir = '/work',
|
||||
outputBase = 'output',
|
||||
outputExt = 'png',
|
||||
destPath,
|
||||
} = opts
|
||||
|
||||
const sandbox = Settings.sandboxConversion || {}
|
||||
const image = sandbox.image || 'sharelatex/pdftocairo:24.02'
|
||||
const socketPath = sandbox.dockerSocketPath || '/var/run/docker.sock'
|
||||
const hostDir = sandbox.hostDir // daemon-host side of the scratch dir
|
||||
const containerDir = sandbox.containerDir // overleaf-container side
|
||||
const memoryBytes = sandbox.memoryBytes || 512 * 1024 * 1024
|
||||
const timeoutMs = sandbox.timeoutMs || 40000
|
||||
|
||||
if (!hostDir || !containerDir) {
|
||||
throw new ConversionError(
|
||||
'sandbox conversion is not configured: set CONVERSION_SANDBOX_HOST_DIR (Docker-daemon side) and CONVERSION_SANDBOX_DIR (overleaf-container side) to the two views of the same directory'
|
||||
)
|
||||
}
|
||||
|
||||
const runId = crypto.randomBytes(8).toString('hex')
|
||||
const runName = `filestore-convert-${runId}`
|
||||
const runDir = Path.join(containerDir, runId) // path used by THIS process (container side)
|
||||
const hostRunDir = Path.join(hostDir, runId) // same dir from the daemon side (bind source)
|
||||
const inputHostPath = Path.join(runDir, 'input.pdf')
|
||||
const outputHostPath = Path.join(runDir, `${outputBase}.${outputExt}`)
|
||||
|
||||
const t0 = Date.now()
|
||||
let containerCreated = false
|
||||
try {
|
||||
// Guard against leftover containers from an interrupted previous run.
|
||||
await _sweepLeftovers(socketPath)
|
||||
|
||||
Fs.mkdirSync(runDir, { mode: 0o770 })
|
||||
Fs.copyFileSync(sourcePath, inputHostPath)
|
||||
|
||||
// Container-absolute path of the source file, for argv remapping sanity
|
||||
const sourceInContainer = Path.join(workDir, 'input.pdf')
|
||||
if (!containerArgs.some((a) => a === sourceInContainer)) {
|
||||
throw new ConversionError(
|
||||
`sandbox argv does not reference ${sourceInContainer} — refusing to run`
|
||||
)
|
||||
}
|
||||
|
||||
// Create (the converter images are scratch-based: no ENTRYPOINT, so a bare
|
||||
// Cmd list is what CLSI runs successfully too).
|
||||
const createBody = {
|
||||
Image: image,
|
||||
Cmd: containerArgs,
|
||||
WorkingDir: workDir,
|
||||
// scratch-based images have no passwd entries — run as the numeric
|
||||
// uid:gid of this service (it owns the files on both sides of the bind).
|
||||
User: `${process.getuid()}:${process.getgid()}`,
|
||||
HostConfig: {
|
||||
Binds: [`${hostRunDir}:${workDir}:rw`],
|
||||
CapDrop: ['ALL'],
|
||||
SecurityOpt: ['no-new-privileges'],
|
||||
NetworkMode: 'none',
|
||||
Memory: memoryBytes,
|
||||
MemorySwap: 0,
|
||||
PidsLimit: 64,
|
||||
},
|
||||
}
|
||||
const created = await request(socketPath, 'POST', `/${API_VERSION}/containers/create?name=${runName}`, {
|
||||
body: createBody,
|
||||
timeoutMs: 60000,
|
||||
})
|
||||
if (created.status >= 300 || !created.body?.Id) {
|
||||
const msg = created.body?.message || JSON.stringify(created.body) || `status ${created.status}`
|
||||
throw new ConversionError(`failed to create sandbox container: ${msg}`)
|
||||
}
|
||||
containerCreated = true
|
||||
logger.info({ image, runName, hostDir: hostRunDir, containerDir: runDir }, 'sandbox conversion: container created')
|
||||
|
||||
await request(socketPath, 'POST', `/${API_VERSION}/containers/${runName}/start`, { timeoutMs: 60000 })
|
||||
|
||||
// Wait with a real deadline; kill on breach (the /wait call is a blocking
|
||||
// HTTP call that carries no data, so it gets its own generous budget).
|
||||
let exitCode = -1
|
||||
let killed = false
|
||||
try {
|
||||
// NOTE: this daemon (Docker 29 / API ≥1.44) requires POST for /wait; GET
|
||||
// returns 404. (dockerode does the same, which is how CLSI waits.)
|
||||
const waited = await request(socketPath, 'POST', `/${API_VERSION}/containers/${runName}/wait`, {
|
||||
timeoutMs: timeoutMs + 15000,
|
||||
})
|
||||
exitCode = waited.body?.StatusCode ?? -1
|
||||
if (exitCode === -1 && waited.status !== 200) {
|
||||
throw new Error(`unexpected /wait status ${waited.status}: ${waited.raw?.toString?.('utf8') || 'no body'}`)
|
||||
}
|
||||
} catch (waitErr) {
|
||||
killed = true
|
||||
await _killAndRemove(socketPath, runName)
|
||||
throw new ConversionError(`sandbox conversion timed out (${timeoutMs}ms): ${waitErr.message}`)
|
||||
}
|
||||
|
||||
let logs = ''
|
||||
try {
|
||||
const logsRes = await request(
|
||||
socketPath,
|
||||
'GET',
|
||||
`/${API_VERSION}/containers/${runName}/logs?stdout=1&stderr=1&stdin=0&follow=1`,
|
||||
{ raw: true, timeoutMs: 10000 }
|
||||
)
|
||||
logs = decodeLogStream(logsRes.raw)
|
||||
} catch (err) {
|
||||
logger.warn({ err, runName }, 'sandbox conversion: could not read container logs')
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new ConversionError(
|
||||
`sandbox conversion failed (exit ${exitCode}${killed ? ', killed' : ''})`,
|
||||
{ stderr: logs.trim().slice(-4000), exitCode }
|
||||
)
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = Fs.statSync(outputHostPath)
|
||||
} catch (err) {
|
||||
throw new ConversionError(`sandbox conversion produced no output file`, { stderr: logs.trim().slice(-4000) })
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new ConversionError('sandbox conversion output is not a regular file', {
|
||||
stderr: logs.trim().slice(-4000),
|
||||
})
|
||||
}
|
||||
|
||||
Fs.copyFileSync(outputHostPath, destPath)
|
||||
logger.info(
|
||||
{ runName, sizeBytes: stat.size, durationMs: Date.now() - t0 },
|
||||
'sandbox conversion completed'
|
||||
)
|
||||
return { container: runName, exitCode, outputHostPath: destPath, durationMs: Date.now() - t0 }
|
||||
} finally {
|
||||
// Best-effort cleanup: container + scratch dir (the dir holds the original
|
||||
// PDF — do not leave it behind).
|
||||
if (containerCreated) {
|
||||
await _killAndRemove(socketPath, runName).catch((e) => {
|
||||
logger.warn({ err: e, runName }, 'sandbox conversion: cleanup failed')
|
||||
})
|
||||
}
|
||||
Fs.rmSync(runDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function _killAndRemove(socketPath, name) {
|
||||
try {
|
||||
// POST /kill (GET is not served by this daemon); 409 = not running → fine
|
||||
await request(socketPath, 'POST', `/${API_VERSION}/containers/${name}/kill`, { timeoutMs: 10000 })
|
||||
} catch (err) {
|
||||
// not running → fine
|
||||
}
|
||||
try {
|
||||
await request(socketPath, 'DELETE', `/${API_VERSION}/containers/${name}?force=1`, { timeoutMs: 10000 })
|
||||
} catch (err) {
|
||||
// already gone → fine
|
||||
}
|
||||
}
|
||||
|
||||
export default { runConversion }
|
||||
@@ -99,8 +99,29 @@ const settings = {
|
||||
|
||||
enableConversions: process.env.ENABLE_CONVERSIONS === 'true',
|
||||
|
||||
// PDF→image conversion backend (template gallery previews/thumbnails):
|
||||
// 'imagemagick' – local, via ImageMagick `convert` (default; binary required)
|
||||
// 'pdftocairo' – local, via the `pdftocairo` binary (poppler)
|
||||
// 'pdftocairo-sandbox' – runs `pdftocairo` in a throwaway sibling container:
|
||||
// no network, CapDrop ALL, no-new-privileges, memory
|
||||
// cap (see app/js/SandboxConverterRunner.js).
|
||||
// requires a Docker socket + a daemon-host-visible
|
||||
// scratch dir (CONVERSION_SANDBOX_HOST_DIR).
|
||||
converter: process.env.CONVERTER || 'imagemagick',
|
||||
|
||||
sandboxConversion: {
|
||||
image: process.env.CONVERSION_SANDBOX_IMAGE || 'sharelatex/pdftocairo:24.02',
|
||||
dockerSocketPath: process.env.CONVERSION_DOCKER_SOCKET || '/var/run/docker.sock',
|
||||
// The scratch dir has two views of the same path (the overleaf container
|
||||
// mounts ./data): `containerDir` for this process, `hostDir` for the Docker
|
||||
// daemon's bind mount. Both must be set when sandbox conversion is used.
|
||||
hostDir: process.env.CONVERSION_SANDBOX_HOST_DIR || null,
|
||||
containerDir: process.env.CONVERSION_SANDBOX_DIR || null,
|
||||
memoryBytes:
|
||||
parseInt(process.env.CONVERSION_SANDBOX_MEMORY_BYTES, 10) || 512 * 1024 * 1024,
|
||||
timeoutMs: parseInt(process.env.CONVERSION_SANDBOX_TIMEOUT_MS, 10) || 40000,
|
||||
},
|
||||
|
||||
gracefulShutdownDelayInMs:
|
||||
parseInt(process.env.GRACEFUL_SHUTDOWN_DELAY_SECONDS ?? '30', 10) * 1000,
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -1199,8 +1199,8 @@ function _finaliseRequest(projectId, options, project, docs, files) {
|
||||
timeout: options.timeout,
|
||||
imageName: project.imageName,
|
||||
draft: Boolean(options.draft),
|
||||
// enable for premium compiles only
|
||||
png2pdf: Boolean(options.png2pdf) && hasPremiumCompiles,
|
||||
// CE: premium-compile (alpha/priority) gate removed
|
||||
png2pdf: Boolean(options.png2pdf),
|
||||
// enable for premium compiles on an image that has a checkpointing build
|
||||
enableCheckpoint:
|
||||
Boolean(options.checkpointing) &&
|
||||
|
||||
@@ -453,7 +453,8 @@ const _CompileController = {
|
||||
enablePng2Pdf,
|
||||
enableCheckpointCompiles,
|
||||
} = await _getSplitTestOptions(req, res)
|
||||
if (Features.hasFeature('saas')) {
|
||||
// CE: saas feature gate removed so png2pdf / compile-from-history options are sent
|
||||
if (true) {
|
||||
options.compileFromClsiCache = true
|
||||
options.populateClsiCache = true
|
||||
options.compileFromHistory = compileFromHistory
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -604,6 +604,58 @@ const EditorController = {
|
||||
)
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// LanguageTool settings (project-wide), mirrored to all clients
|
||||
// in the room (see use-project-wide-settings-socket-listener).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
setLanguageToolEnabled(projectId, enabled, callback) {
|
||||
ProjectOptionsHandler.setLanguageToolEnabled(projectId, enabled, function (
|
||||
err
|
||||
) {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
EditorRealTimeController.emitToRoom(
|
||||
projectId,
|
||||
'languageToolEnabledUpdated',
|
||||
enabled
|
||||
)
|
||||
callback()
|
||||
})
|
||||
},
|
||||
|
||||
setLanguageToolPicky(projectId, picky, callback) {
|
||||
ProjectOptionsHandler.setLanguageToolPicky(projectId, picky, function (err) {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
EditorRealTimeController.emitToRoom(
|
||||
projectId,
|
||||
'languageToolPickyUpdated',
|
||||
picky
|
||||
)
|
||||
callback()
|
||||
})
|
||||
},
|
||||
|
||||
setLanguageToolLanguage(projectId, language, callback) {
|
||||
ProjectOptionsHandler.setLanguageToolLanguage(
|
||||
projectId,
|
||||
language,
|
||||
function (err) {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
EditorRealTimeController.emitToRoom(
|
||||
projectId,
|
||||
'languageToolLanguageUpdated',
|
||||
language
|
||||
)
|
||||
callback()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
setPublicAccessLevel(projectId, newAccessLevel, callback) {
|
||||
async.series(
|
||||
[
|
||||
@@ -747,6 +799,13 @@ EditorController.promises = {
|
||||
setImageName: promisify(EditorController.setImageName),
|
||||
setPng2pdf: promisify(EditorController.setPng2pdf),
|
||||
setSpellCheckLanguage: promisify(EditorController.setSpellCheckLanguage),
|
||||
setLanguageToolEnabled: promisify(
|
||||
EditorController.setLanguageToolEnabled
|
||||
),
|
||||
setLanguageToolPicky: promisify(EditorController.setLanguageToolPicky),
|
||||
setLanguageToolLanguage: promisify(
|
||||
EditorController.setLanguageToolLanguage
|
||||
),
|
||||
setPublicAccessLevel: promisify(EditorController.setPublicAccessLevel),
|
||||
setRootDoc: promisify(EditorController.setRootDoc),
|
||||
setMainBibliographyDoc: promisify(EditorController.setMainBibliographyDoc),
|
||||
|
||||
@@ -109,6 +109,9 @@ const updateProjectSettingsSchema = z.object({
|
||||
rootDocId: zz.objectId().optional(),
|
||||
spellCheckLanguage: z.string().optional(),
|
||||
referenceFormat: z.enum(['bibtex', 'biblatex']).optional(),
|
||||
languageToolEnabled: z.boolean().optional(),
|
||||
languageToolPicky: z.boolean().optional(),
|
||||
languageToolLanguage: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -127,6 +130,9 @@ const updateProjectSettingsFallbackSchema = z.object({
|
||||
rootDocId: zz.objectId().optional(),
|
||||
spellCheckLanguage: z.string().optional(),
|
||||
referenceFormat: z.enum(['bibtex', 'biblatex']).optional(),
|
||||
languageToolEnabled: z.boolean().optional(),
|
||||
languageToolPicky: z.boolean().optional(),
|
||||
languageToolLanguage: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -228,6 +234,27 @@ const _ProjectController = {
|
||||
)
|
||||
}
|
||||
|
||||
if (body.languageToolEnabled != null) {
|
||||
await EditorController.promises.setLanguageToolEnabled(
|
||||
projectId,
|
||||
body.languageToolEnabled
|
||||
)
|
||||
}
|
||||
|
||||
if (body.languageToolPicky != null) {
|
||||
await EditorController.promises.setLanguageToolPicky(
|
||||
projectId,
|
||||
body.languageToolPicky
|
||||
)
|
||||
}
|
||||
|
||||
if (body.languageToolLanguage != null) {
|
||||
await EditorController.promises.setLanguageToolLanguage(
|
||||
projectId,
|
||||
body.languageToolLanguage
|
||||
)
|
||||
}
|
||||
|
||||
if (body.rootDocId != null) {
|
||||
await EditorController.promises.setRootDoc(projectId, body.rootDocId)
|
||||
}
|
||||
|
||||
@@ -127,6 +127,29 @@ const ProjectOptionsHandler = {
|
||||
const update = { referenceFormat: newReferenceFormat }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// LanguageTool settings (project-wide; shared across collaborators).
|
||||
// Unset values mean the client defaults: enabled=true, picky=true,
|
||||
// language='auto' — i.e. picky level is the default selection.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
async setLanguageToolEnabled(projectId, enabled) {
|
||||
const conditions = { _id: projectId }
|
||||
const update = { languageToolEnabled: Boolean(enabled) }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
|
||||
async setLanguageToolPicky(projectId, picky) {
|
||||
const conditions = { _id: projectId }
|
||||
const update = { languageToolPicky: Boolean(picky) }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
|
||||
async setLanguageToolLanguage(projectId, language) {
|
||||
const conditions = { _id: projectId }
|
||||
const update = { languageToolLanguage: language || 'auto' }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
@@ -144,5 +167,14 @@ export default {
|
||||
ProjectOptionsHandler.setHistoryRangesSupport
|
||||
),
|
||||
setReferenceFormat: callbackify(ProjectOptionsHandler.setReferenceFormat),
|
||||
setLanguageToolEnabled: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolEnabled
|
||||
),
|
||||
setLanguageToolPicky: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolPicky
|
||||
),
|
||||
setLanguageToolLanguage: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolLanguage
|
||||
),
|
||||
promises: ProjectOptionsHandler,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +306,7 @@ const UserPagesController = {
|
||||
title: Settings.nav?.login_support_title || 'login',
|
||||
login_support_title: Settings.nav?.login_support_title,
|
||||
login_support_text: Settings.nav?.login_support_text,
|
||||
local_login_mask_enable: Settings.enableLocalLoginMask,
|
||||
metadata,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -425,6 +425,9 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
||||
linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId,
|
||||
githubSyncEnabled: !!Settings.githubSync?.clientID && !!Settings.githubSync?.clientSecret,
|
||||
zoteroEnabled: !!Settings.zotero?.clientKey && !!Settings.zotero?.clientSecret,
|
||||
wakaTimeEnabled: Settings.wakatime?.enabled !== false,
|
||||
wakaTimeDebugLogging: !!Settings.wakatime?.debugLogging,
|
||||
languageToolAvailable: !!Settings.languageToolURL,
|
||||
enablePandocConversions: Settings.enablePandocConversions,
|
||||
mixpanelLabsToken:
|
||||
Settings.labs?.enable && Settings.analytics?.mixpanel?.labsToken,
|
||||
|
||||
@@ -36,7 +36,7 @@ footer.site-footer
|
||||
a(href=item.url class=item.class aria-label=item.label) !{item.text}
|
||||
else
|
||||
| !{item.text}
|
||||
if settings.env === 'server-ce'
|
||||
if settings.env === 'server-ce' && settings.show_unsupport_warning
|
||||
.row
|
||||
.col-12
|
||||
p.copyright-notice
|
||||
|
||||
@@ -2,6 +2,7 @@ extends ../layout-website-redesign
|
||||
|
||||
block vars
|
||||
- isWebsiteRedesign = true
|
||||
- suppressNavbarRight = true
|
||||
|
||||
block content
|
||||
main#main-content.content
|
||||
@@ -13,41 +14,42 @@ block content
|
||||
h1 !{login_support_title}
|
||||
else
|
||||
h1 #{translate("log_in")}
|
||||
form(name='loginForm' data-ol-async-form action='/login' method='POST')
|
||||
input(name='_csrf' type='hidden' value=csrfToken)
|
||||
+formMessagesNewStyle
|
||||
+customFormMessageNewStyle('invalid-password-retry-or-reset', 'danger')
|
||||
| !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])}
|
||||
span.visually-hidden(id='resetPasswordDescription')
|
||||
| #{translate('reset_password_link')}
|
||||
+customFormMessageNewStyle('password-compromised')
|
||||
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
|
||||
.form-group
|
||||
label(for='email') #{translate("email")}
|
||||
input#email.form-control(
|
||||
name='email'
|
||||
type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email'
|
||||
required
|
||||
placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com'
|
||||
autofocus='true'
|
||||
autocomplete='username'
|
||||
)
|
||||
.form-group
|
||||
label(for='password') #{translate("password")}
|
||||
input#password.form-control(
|
||||
name='password'
|
||||
type='password'
|
||||
autocomplete='current-password'
|
||||
required
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(type='submit' data-ol-disabled-inflight)
|
||||
span(data-ol-inflight='idle') #{translate("login")}
|
||||
span(hidden data-ol-inflight='pending') #{translate("logging_in")}…
|
||||
a.float-end(href='/user/password/reset') #{translate("forgot_your_password")}?
|
||||
if login_support_text
|
||||
hr
|
||||
p.text-center !{login_support_text}
|
||||
if local_login_mask_enable
|
||||
form(name='loginForm' data-ol-async-form action='/login' method='POST')
|
||||
input(name='_csrf' type='hidden' value=csrfToken)
|
||||
+formMessagesNewStyle
|
||||
+customFormMessageNewStyle('invalid-password-retry-or-reset', 'danger')
|
||||
| !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])}
|
||||
span.visually-hidden(id='resetPasswordDescription')
|
||||
| #{translate('reset_password_link')}
|
||||
+customFormMessageNewStyle('password-compromised')
|
||||
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
|
||||
.form-group
|
||||
label(for='email') #{translate("email")}
|
||||
input#email.form-control(
|
||||
name='email'
|
||||
type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email'
|
||||
required
|
||||
placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com'
|
||||
autofocus='true'
|
||||
autocomplete='username'
|
||||
)
|
||||
.form-group
|
||||
label(for='password') #{translate("password")}
|
||||
input#password.form-control(
|
||||
name='password'
|
||||
type='password'
|
||||
autocomplete='current-password'
|
||||
required
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(type='submit' data-ol-disabled-inflight)
|
||||
span(data-ol-inflight='idle') #{translate("login")}
|
||||
span(hidden data-ol-inflight='pending') #{translate("logging_in")}…
|
||||
a.float-end(href='/user/password/reset') #{translate("forgot_your_password")}?
|
||||
if login_support_text
|
||||
hr
|
||||
p.text-center !{login_support_text}
|
||||
if settings.saml && settings.saml.enable
|
||||
.actions(style='margin-top: 30px;')
|
||||
a.button.btn-secondary.btn(
|
||||
|
||||
@@ -79,6 +79,8 @@ const safeCompilers = ['xelatex', 'pdflatex', 'latex', 'lualatex']
|
||||
|
||||
module.exports = {
|
||||
env: 'server-ce',
|
||||
// show the CE "free unsupported software" disclaimer in the footer; set SHOW_UNSUPPORT_WARNING=false to hide it
|
||||
show_unsupport_warning: process.env.SHOW_UNSUPPORT_WARNING !== 'false',
|
||||
|
||||
limits: {
|
||||
httpGlobalAgentMaxSockets: 300,
|
||||
@@ -1032,7 +1034,16 @@ module.exports = {
|
||||
),
|
||||
],
|
||||
contactUsModal: [],
|
||||
sourceEditorExtensions: [],
|
||||
sourceEditorExtensions: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/languagetool/frontend/js/languagetool-extension'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/wakatime/frontend/js/editor/wakatime-source-editor-extension.ts'
|
||||
),
|
||||
],
|
||||
sourceEditorVisualExtensions: [],
|
||||
sourceEditorComponents: [],
|
||||
pdfLogEntryHeaderActionComponents: [],
|
||||
@@ -1076,6 +1087,10 @@ module.exports = {
|
||||
__dirname,
|
||||
'../modules/github-sync/frontend/js/components/github-sync-widget.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/wakatime/frontend/js/components/wakatime-widget.tsx'
|
||||
),
|
||||
],
|
||||
referenceLinkingWidgets: [
|
||||
Path.resolve(
|
||||
@@ -1170,10 +1185,19 @@ module.exports = {
|
||||
__dirname,
|
||||
'../modules/zotero/frontend/js/components/zotero-integration-card.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/wakatime/frontend/js/components/wakatime-integration-card.tsx'
|
||||
),
|
||||
],
|
||||
referenceSearchSetting: [],
|
||||
settingsModalEditorTabSections: [],
|
||||
settingsModalSpellcheckSections: [],
|
||||
settingsModalSpellcheckSections: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/languagetool/frontend/js/languagetool-section'
|
||||
),
|
||||
],
|
||||
editorFloatingMenuActions: [],
|
||||
errorLogsComponents: [],
|
||||
referenceIndices: [
|
||||
@@ -1196,6 +1220,7 @@ module.exports = {
|
||||
'symbol-palette',
|
||||
'reference-picker',
|
||||
'track-changes',
|
||||
'languagetool',
|
||||
'authentication/ldap',
|
||||
'authentication/saml',
|
||||
'authentication/oidc',
|
||||
@@ -1205,7 +1230,24 @@ module.exports = {
|
||||
'git-bridge',
|
||||
'github-sync',
|
||||
'zotero',
|
||||
'wakatime',
|
||||
],
|
||||
wakatime: {
|
||||
// global on/off (widget + routes); default: on (matches PR 249 behavior)
|
||||
enabled: process.env.WAKATIME_ENABLED !== 'false',
|
||||
debugLogging: process.env.WAKATIME_DEBUG_LOGGING === 'true',
|
||||
// Deployment-managed identity (bundled Wakapi, see
|
||||
// tools/wakapi_server_compose): NO per-user registration, NO API keys —
|
||||
// the logged-in user's email is asserted to Wakapi via a trusted
|
||||
// header (WAKAPI_TRUSTED_HEADER_AUTH on the Wakapi side) and Wakapi
|
||||
// creates the user automatically on first use. A manually linked key
|
||||
// (wakatime.com or any other WakaTime server) always takes priority.
|
||||
sharedIdentity: process.env.WAKATIME_SHARED_IDENTITY === 'true',
|
||||
sharedIdentityApiUrl:
|
||||
process.env.WAKATIME_SHARED_IDENTITY_API_URL ||
|
||||
'http://wakapi:3000/api/compat/wakatime/v1',
|
||||
sharedIdentityHeader: process.env.WAKATIME_SHARED_IDENTITY_HEADER || 'Remote-User',
|
||||
},
|
||||
viewIncludes: {},
|
||||
|
||||
csp: {
|
||||
@@ -1232,6 +1274,8 @@ module.exports = {
|
||||
},
|
||||
|
||||
enablePandocConversions: process.env.ENABLE_PANDOC_CONVERSIONS === 'true',
|
||||
// local login form is shown by default; set ENABLE_LOCAL_LOGIN_MASK=false to hide it (e.g. SSO-only login)
|
||||
enableLocalLoginMask: process.env.ENABLE_LOCAL_LOGIN_MASK !== 'false',
|
||||
|
||||
oauthProviders: {
|
||||
...(process.env.EXTERNAL_AUTH && process.env.EXTERNAL_AUTH.includes('oidc') && {
|
||||
|
||||
@@ -13,6 +13,15 @@ type ProjectSettingsSetterContextValue = {
|
||||
setSpellCheckLanguage: (
|
||||
spellCheckLanguage: ProjectSettings['spellCheckLanguage']
|
||||
) => void
|
||||
setLanguageToolEnabled: (
|
||||
languageToolEnabled: ProjectSettings['languageToolEnabled']
|
||||
) => void
|
||||
setLanguageToolPicky: (
|
||||
languageToolPicky: ProjectSettings['languageToolPicky']
|
||||
) => void
|
||||
setLanguageToolLanguage: (
|
||||
languageToolLanguage: ProjectSettings['languageToolLanguage']
|
||||
) => void
|
||||
setReferenceFormat: (
|
||||
referenceFormat: ProjectSettings['referenceFormat']
|
||||
) => Promise<void>
|
||||
@@ -72,6 +81,12 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setSpellCheckLanguage,
|
||||
referenceFormat,
|
||||
setReferenceFormat,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
} = useProjectWideSettings()
|
||||
|
||||
const {
|
||||
@@ -135,6 +150,12 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setRootDocId,
|
||||
spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
autoComplete,
|
||||
setAutoComplete,
|
||||
autoPairDelimiters,
|
||||
@@ -193,6 +214,12 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setRootDocId,
|
||||
spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
autoComplete,
|
||||
setAutoComplete,
|
||||
autoPairDelimiters,
|
||||
|
||||
+42
@@ -53,6 +53,33 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
const setLanguageToolEnabled = useCallback(
|
||||
(languageToolEnabled: ProjectSettings['languageToolEnabled']) => {
|
||||
if (project) {
|
||||
updateProject({ languageToolEnabled })
|
||||
}
|
||||
},
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
const setLanguageToolPicky = useCallback(
|
||||
(languageToolPicky: ProjectSettings['languageToolPicky']) => {
|
||||
if (project) {
|
||||
updateProject({ languageToolPicky })
|
||||
}
|
||||
},
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
const setLanguageToolLanguage = useCallback(
|
||||
(languageToolLanguage: ProjectSettings['languageToolLanguage']) => {
|
||||
if (project) {
|
||||
updateProject({ languageToolLanguage })
|
||||
}
|
||||
},
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// data is not available on initial mounting
|
||||
const dataAvailable = !!project
|
||||
@@ -63,6 +90,9 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
socket.on('png2pdfUpdated', setPng2pdf)
|
||||
socket.on('spellCheckLanguageUpdated', setSpellCheckLanguage)
|
||||
socket.on('referenceFormatUpdated', setReferenceFormat)
|
||||
socket.on('languageToolEnabledUpdated', setLanguageToolEnabled)
|
||||
socket.on('languageToolPickyUpdated', setLanguageToolPicky)
|
||||
socket.on('languageToolLanguageUpdated', setLanguageToolLanguage)
|
||||
return () => {
|
||||
socket.removeListener('compilerUpdated', setCompiler)
|
||||
socket.removeListener('imageNameUpdated', setImageName)
|
||||
@@ -72,6 +102,15 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
setSpellCheckLanguage
|
||||
)
|
||||
socket.removeListener('referenceFormatUpdated', setReferenceFormat)
|
||||
socket.removeListener(
|
||||
'languageToolEnabledUpdated',
|
||||
setLanguageToolEnabled
|
||||
)
|
||||
socket.removeListener('languageToolPickyUpdated', setLanguageToolPicky)
|
||||
socket.removeListener(
|
||||
'languageToolLanguageUpdated',
|
||||
setLanguageToolLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -82,5 +121,8 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
setReferenceFormat,
|
||||
setPng2pdf,
|
||||
setSpellCheckLanguage,
|
||||
setLanguageToolEnabled,
|
||||
setLanguageToolPicky,
|
||||
setLanguageToolLanguage,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ProjectSettings } from '../utils/api'
|
||||
import useRootDocId from './use-root-doc-id'
|
||||
import useSaveProjectSettings from './use-save-project-settings'
|
||||
import useSetSpellCheckLanguage from './use-set-spell-check-language'
|
||||
import useSetLanguageToolSetting from './use-set-language-tool'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import getMeta from '@/utils/meta'
|
||||
@@ -49,6 +50,9 @@ export default function useProjectWideSettings() {
|
||||
|
||||
const { setRootDocId, rootDocId } = useRootDocId()
|
||||
const setSpellCheckLanguage = useSetSpellCheckLanguage()
|
||||
const setLanguageToolEnabled = useSetLanguageToolSetting('languageToolEnabled')
|
||||
const setLanguageToolPicky = useSetLanguageToolSetting('languageToolPicky')
|
||||
const setLanguageToolLanguage = useSetLanguageToolSetting('languageToolLanguage')
|
||||
|
||||
return {
|
||||
compiler: project?.compiler,
|
||||
@@ -63,5 +67,11 @@ export default function useProjectWideSettings() {
|
||||
setRootDocId,
|
||||
spellCheckLanguage: project?.spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled: project?.languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky: project?.languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage: project?.languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useCallback } from 'react'
|
||||
import { type ProjectSettings } from '../utils/api'
|
||||
import useSaveProjectSettings from './use-save-project-settings'
|
||||
|
||||
type LanguageToolSettingKey =
|
||||
| 'languageToolEnabled'
|
||||
| 'languageToolPicky'
|
||||
| 'languageToolLanguage'
|
||||
|
||||
/**
|
||||
* Setter for the LanguageTool project settings (enabled / picky / language),
|
||||
* following the `useSet*` hook pattern of this directory.
|
||||
*/
|
||||
export default function useSetLanguageToolSetting(
|
||||
key: LanguageToolSettingKey
|
||||
) {
|
||||
const saveProjectSettings = useSaveProjectSettings()
|
||||
|
||||
return useCallback(
|
||||
(newValue: ProjectSettings[LanguageToolSettingKey]) => {
|
||||
saveProjectSettings(key, newValue).catch(() => {})
|
||||
},
|
||||
[key, saveProjectSettings]
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,11 @@ export interface ProjectSettings {
|
||||
name: string
|
||||
mainBibliographyDocId?: string
|
||||
referenceFormat?: 'bibtex' | 'biblatex'
|
||||
// LanguageTool grammar check (project-wide; defaults when unset:
|
||||
// enabled=true, picky=true → level=picky, language='auto')
|
||||
languageToolEnabled?: boolean
|
||||
languageToolPicky?: boolean
|
||||
languageToolLanguage?: string
|
||||
}
|
||||
|
||||
type SaveUserSettings = Partial<
|
||||
|
||||
@@ -12,7 +12,8 @@ const integrationPanelComponents = allIntegrationPanelComponents.filter(
|
||||
({ path }) =>
|
||||
(getMeta('ol-gitBridgeEnabled') || !path.includes('git-bridge')) &&
|
||||
(getMeta('ol-ExposedSettings').githubSyncEnabled || !path.includes('github-sync')) &&
|
||||
(getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero'))
|
||||
(getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero')) &&
|
||||
(getMeta('ol-ExposedSettings').wakaTimeEnabled || !path.includes('wakatime'))
|
||||
)
|
||||
|
||||
export default function IntegrationsPanel() {
|
||||
|
||||
@@ -20,10 +20,12 @@ const availableLangFeedbackLinkingWidgets = importOverleafModules(
|
||||
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
|
||||
const githubSyncEnabled = getMeta('ol-ExposedSettings').githubSyncEnabled
|
||||
const zoteroEnabled = getMeta('ol-ExposedSettings').zoteroEnabled
|
||||
const wakaTimeEnabled = getMeta('ol-ExposedSettings').wakaTimeEnabled
|
||||
|
||||
const availableIntegrationLinkingWidgets = allAvailableIntegrationLinkingWidgets.filter(
|
||||
({ path }) =>
|
||||
(githubSyncEnabled || !path.includes('github-sync'))
|
||||
(githubSyncEnabled || !path.includes('github-sync')) &&
|
||||
(wakaTimeEnabled || !path.includes('wakatime'))
|
||||
)
|
||||
|
||||
function LinkingSection() {
|
||||
@@ -55,7 +57,7 @@ function LinkingSection() {
|
||||
}[]
|
||||
|
||||
const renderSyncSection =
|
||||
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled
|
||||
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled || wakaTimeEnabled
|
||||
|
||||
const allIntegrationLinkingWidgets = integrationLinkingWidgets.concat(
|
||||
oauth2ServerComponents
|
||||
|
||||
@@ -513,7 +513,7 @@ class HistoryOTAdapter {
|
||||
|
||||
export const trackChangesAnnotation = Annotation.define()
|
||||
|
||||
const chooseOrigin = (transaction: Transaction) => {
|
||||
export const chooseOrigin = (transaction: Transaction) => {
|
||||
if (transaction.annotation(Transaction.remote)) {
|
||||
return 'remote'
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
|
||||
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
|
||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||
import { SearchQuery } from '@codemirror/search'
|
||||
@@ -109,6 +110,33 @@ function useCodeMirrorScope(view: EditorView) {
|
||||
|
||||
const hunspellManager = useHunspell(spellCheckLanguage)
|
||||
|
||||
// ── LanguageTool grammar check (project-wide settings) ─────────────────────
|
||||
// Stored on the project (shared by all collaborators). Defaults when unset:
|
||||
// enabled + picky ON → level=picky. Values are handed to the CodeMirror
|
||||
// extension through `createExtensions` below, and every change is
|
||||
// announced with a window event the extension listens for.
|
||||
const languageTool = {
|
||||
available:
|
||||
getMeta('ol-ExposedSettings')?.languageToolAvailable === true,
|
||||
// off for read-only views (like spell-checking)
|
||||
enabled:
|
||||
(permissions.write || permissions.trackedWrite) &&
|
||||
project?.languageToolEnabled !== false,
|
||||
picky: project?.languageToolPicky !== false,
|
||||
language: project?.languageToolLanguage || 'auto',
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('lt:settings-changed', { detail: languageTool })
|
||||
)
|
||||
}, [
|
||||
languageTool.available,
|
||||
languageTool.enabled,
|
||||
languageTool.picky,
|
||||
languageTool.language,
|
||||
])
|
||||
|
||||
const { showVisual: visual, trackChanges } = useEditorPropertiesContext()
|
||||
|
||||
const { referenceKeys, searchLocalReferences } = useReferencesContext()
|
||||
@@ -362,6 +390,7 @@ function useCodeMirrorScope(view: EditorView) {
|
||||
settings: settingsRef.current,
|
||||
phrases: phrasesRef.current,
|
||||
spelling: spellingRef.current,
|
||||
languageTool,
|
||||
showVisual: showVisualRef.current,
|
||||
previewByPath: previewByPathRef.current,
|
||||
currentDocFolder: dirnameRef.current(currentDocument.doc_id),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
function WakaTimeLogo({ size = 40 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 340 340"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M170 20C87.156 20 20 87.156 20 170C20 252.844 87.156 320 170 320C252.844 320 320 252.844 320 170C320 87.156 252.844 20 170 20V20V20Z"
|
||||
stroke="black"
|
||||
strokeWidth="40"
|
||||
/>
|
||||
<path
|
||||
d="M190.183 213.541C188.74 215.443 186.576 216.667 184.151 216.667C183.913 216.667 183.677 216.651 183.443 216.627C183.042 216.579 182.823 216.545 182.606 216.497C182.337 216.434 182.137 216.375 181.94 216.308C181.561 216.176 181.392 216.109 181.228 216.035C180.843 215.849 180.707 215.778 180.572 215.701C180.205 215.478 180.109 215.412 180.014 215.345C179.856 215.233 179.698 215.117 179.547 214.992C179.251 214.746 179.147 214.65 179.044 214.552C178.731 214.241 178.531 214.018 178.341 213.785C177.982 213.331 177.69 212.888 177.438 212.415L168.6 198.214L159.766 212.415C158.38 214.939 155.874 216.667 152.995 216.667C150.106 216.667 147.588 214.926 146.243 212.346L107.607 156.061C106.337 154.529 105.556 152.499 105.556 150.258C105.556 145.514 109.043 141.665 113.344 141.665C116.127 141.665 118.564 143.282 119.942 145.708L152.555 193.9L161.735 178.952C163.058 176.288 165.626 174.478 168.575 174.478C171.273 174.478 173.652 175.996 175.049 178.298L184.517 193.839L235.684 120.583C237.075 118.226 239.475 116.667 242.213 116.667C246.514 116.667 250 120.514 250 125.258C250 127.332 249.337 129.232 248.23 130.715L190.183 213.541Z"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
strokeWidth="10"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default WakaTimeLogo
|
||||
@@ -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);
|
||||
|
||||
@@ -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 and 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(
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# LanguageTool module
|
||||
|
||||
Grammar and style checking in the Overleaf source editor, powered by an
|
||||
external [LanguageTool](https://languagetool.org) server (API v2).
|
||||
|
||||
Based on the upstream PR:
|
||||
<https://github.com/yu-i-i/overleaf-cep/pull/179> (commit 8337e00), adapted
|
||||
for ext-ce-v6.3.1 with:
|
||||
|
||||
- **Picky level as the default** and an on/off "Picky mode" toggle in the
|
||||
project settings (the deployed LT fork, `erikvl87/languagetool`, supports
|
||||
`default` + `picky`; other levels return HTTP 400).
|
||||
- **Project-wide settings** (stored on the project document, shared by all
|
||||
collaborators, synced over the editor socket):
|
||||
- `languageToolEnabled` — grammar check on/off (default: on)
|
||||
- `languageToolPicky` — picky level on/off (default: **on** → `level=picky`)
|
||||
- `languageToolLanguage` — LT language code, e.g. `en-US`, `de` (default: `auto`)
|
||||
- **Dark/light theme support**: tooltip + underlines use the Overleaf themed
|
||||
CSS variables (`--bg-primary-themed`, `--content-primary-themed`, ...)
|
||||
with light-mode fallbacks (fixes the dark-theme tooltip bug from the PR).
|
||||
|
||||
## Server (Overleaf) environment
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| ----------------------- | ----------------- | --------------------------------------- |
|
||||
| `LANGUAGE_TOOL_ENABLED` | _unset_ | must be exactly `true` to activate |
|
||||
| `LANGUAGE_TOOL_HOST` | `languagetool` | host of the LT service |
|
||||
| `LANGUAGE_TOOL_PORT` | `8010` | LT API port |
|
||||
| `LANGUAGE_TOOL_URL` | derived | overrides host+port when set |
|
||||
|
||||
The overleaf container needs network access to the LT service. In this
|
||||
deployment the overleaf service joins the external
|
||||
`languagetools_server_compose_default` network (see
|
||||
`/data_1/docker/compose/overleaf/compose.yaml`) and talks to
|
||||
`http://languagetool:8010`.
|
||||
|
||||
## Endpoints (authenticated)
|
||||
|
||||
- `GET /languagetool/languages` — LT language list (used by the settings UI)
|
||||
- `POST /languagetool/check` — JSON body `{ language, level?, text? | data? }`;
|
||||
proxied to LT `/v2/check` with a 100 KB cap and LaTeX-noise `disabledRules`.
|
||||
**Exempt from CSRF** (`webRouter.csrf.disableDefaultCsrfProtection` in the
|
||||
router): it is a stateless, login-gated query — and long-lived editor tabs
|
||||
outlive session rotations, where the page's meta token would stale out and
|
||||
403 every check. The client still sends `X-CSRF-Token`; it passes when the
|
||||
session is fresh.
|
||||
|
||||
## Frontend
|
||||
|
||||
- `frontend/js/languagetool-extension.ts` — CodeMirror 6 extension
|
||||
(wired via the `sourceEditorExtensions` slot in
|
||||
`services/web/config/settings.defaults.js`). Wavy underlines + hover
|
||||
tooltip with replacement buttons; LaTeX-aware annotation via the Lezer
|
||||
syntax tree (regex fallback); debounced 2 s; aborts stale requests;
|
||||
filters TYPOS while the Hunspell spellchecker is active.
|
||||
- `frontend/js/languagetool-section.tsx` +
|
||||
`frontend/js/components/languagetool-settings.tsx` — Settings modal,
|
||||
Spelling and Language → **LanguageTool** (wired via the
|
||||
`settingsModalSpellcheckSections` slot).
|
||||
|
||||
Settings reach the editor through `options.languageTool`
|
||||
(`use-codemirror-scope.ts`) and the `lt:settings-changed` window event.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
index.mjs module entry (env-gated router)
|
||||
app/src/LanguageToolRouter.mjs
|
||||
app/src/LanguageToolController.mjs
|
||||
frontend/js/languagetool-extension.ts
|
||||
frontend/js/languagetool-section.tsx
|
||||
frontend/js/components/languagetool-settings.tsx
|
||||
frontend/js/utils/latex-to-annotations.ts
|
||||
```
|
||||
|
||||
Core Overleaf changes (see git diff):
|
||||
|
||||
- `services/web/config/settings.defaults.js`
|
||||
(`sourceEditorExtensions`, `settingsModalSpellcheckSections`,
|
||||
`moduleImportSequence`)
|
||||
- `services/web/app/src/infrastructure/ExpressLocals.mjs`
|
||||
(exposed setting `languageToolAvailable`)
|
||||
- `services/web/types/exposed-settings.ts`
|
||||
- `services/web/app/src/Features/Project/ProjectController.mjs`
|
||||
(project-settings schema + update)
|
||||
- `services/web/app/src/Features/Project/ProjectOptionsHandler.mjs` (persist)
|
||||
- `services/web/app/src/Features/Editor/EditorController.mjs` (socket emit)
|
||||
- `services/web/frontend/js/features/ide-settings/...` (settings UI context,
|
||||
hooks, socket listener, `api.ts`)
|
||||
- `services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts`
|
||||
@@ -0,0 +1,138 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
const MAX_TEXT_SIZE = 100_000 // 100 KB limit for safety
|
||||
|
||||
// Levels understood by the LanguageTool fork we deploy
|
||||
// (erikvl87/languagetool supports: default and picky).
|
||||
const ALLOWED_LEVELS = new Set([
|
||||
'default',
|
||||
'normal',
|
||||
'careful',
|
||||
'meticulous',
|
||||
'picky',
|
||||
])
|
||||
|
||||
const LanguageToolController = {
|
||||
|
||||
/**
|
||||
* GET /languagetool/languages
|
||||
* Returns the list of supported languages from the LT service
|
||||
*/
|
||||
async getLanguages(req, res, next) {
|
||||
try {
|
||||
const response = await fetch(`${Settings.languageToolURL}/v2/languages`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
{ status: response.status },
|
||||
'LanguageTool languages request failed'
|
||||
)
|
||||
return res.status(502).json({ error: 'LanguageTool request failed' })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
res.json(data)
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error fetching LanguageTool languages')
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /languagetool/check
|
||||
* Proxies text/annotation data to the LanguageTool service and returns matches.
|
||||
* Accepts JSON body: { language, level?, text?, data? }
|
||||
*
|
||||
* `level` is optional; when omitted the LanguageTool default level is used.
|
||||
* The client sends `picky` when the project's "picky mode" is enabled —
|
||||
* picky is the recommended default in this deployment.
|
||||
*/
|
||||
async check(req, res, next) {
|
||||
const { language = 'auto', level, text, data } = req.body
|
||||
|
||||
if (!text && !data) {
|
||||
return res.status(400).json({ error: 'text or data is required' })
|
||||
}
|
||||
|
||||
if (level && !ALLOWED_LEVELS.has(level)) {
|
||||
return res.status(400).json({ error: `unknown level: ${level}` })
|
||||
}
|
||||
|
||||
// Sanitize: reject oversized requests
|
||||
const contentStr =
|
||||
typeof data === 'string'
|
||||
? data
|
||||
: typeof data === 'object'
|
||||
? JSON.stringify(data)
|
||||
: text || ''
|
||||
|
||||
if (contentStr.length > MAX_TEXT_SIZE) {
|
||||
logger.warn(
|
||||
{ size: contentStr.length },
|
||||
'LanguageTool request exceeds size limit, truncating'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('language', language)
|
||||
|
||||
if (level) {
|
||||
params.set('level', level)
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const dataStr =
|
||||
typeof data === 'string' ? data : JSON.stringify(data)
|
||||
params.set('data', dataStr.slice(0, MAX_TEXT_SIZE))
|
||||
} else {
|
||||
params.set('text', (text || '').slice(0, MAX_TEXT_SIZE))
|
||||
}
|
||||
|
||||
// Disable rules that produce excessive false positives in LaTeX documents
|
||||
params.set(
|
||||
'disabledRules',
|
||||
'WHITESPACE_RULE,COMMA_PARENTHESIS_WHITESPACE,CONSECUTIVE_SPACES,DASH_RULE,UPPERCASE_SENTENCE_START'
|
||||
)
|
||||
|
||||
const response = await fetch(`${Settings.languageToolURL}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: params.toString(),
|
||||
timeout: 60_000,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
logger.error(
|
||||
{ status: response.status, level, body: body.slice(0, 500) },
|
||||
'LanguageTool check failed'
|
||||
)
|
||||
return res
|
||||
.status(502)
|
||||
.json({ error: 'LanguageTool check failed', serverStatus: response.status })
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
// On timeout or network error return empty results so the linter stays silent
|
||||
if (err.type === 'request-timeout' || err.name === 'FetchError') {
|
||||
logger.warn({ err }, 'LanguageTool check timed out or failed, returning empty results')
|
||||
return res.json({ matches: [] })
|
||||
}
|
||||
logger.error({ err }, 'Error checking text with LanguageTool')
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default LanguageToolController
|
||||
@@ -0,0 +1,31 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
|
||||
import LanguageToolController from './LanguageToolController.mjs'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.debug({}, 'Init languagetool router')
|
||||
|
||||
// POST /languagetool/check is a stateless grammar query (login required,
|
||||
// no server state changes). Exempt it from CSRF: long-lived editor tabs
|
||||
// hold the page's meta token, and after an in-flight (re-)login the
|
||||
// browser's session has rotated, so the stale token would 403 every
|
||||
// check even though the request is fully authenticated.
|
||||
if (webRouter.csrf && webRouter.csrf.disableDefaultCsrfProtection) {
|
||||
webRouter.csrf.disableDefaultCsrfProtection('/languagetool/check', 'POST')
|
||||
}
|
||||
|
||||
// Authenticated endpoints
|
||||
webRouter.get(
|
||||
'/languagetool/languages',
|
||||
AuthenticationController.requireLogin(),
|
||||
LanguageToolController.getLanguages
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/languagetool/check',
|
||||
AuthenticationController.requireLogin(),
|
||||
LanguageToolController.check
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* LanguageTool settings component — rendered inside the Settings modal
|
||||
* (Spelling and Language → LanguageTool section) via the
|
||||
* `settingsModalSpellcheckSections` slot.
|
||||
*
|
||||
* Persistence: all three settings are PROJECT settings — stored on the
|
||||
* project document so every collaborator sees the same values:
|
||||
* - languageToolEnabled (default: true)
|
||||
* - languageToolPicky (default: true → level=picky is the default)
|
||||
* - languageToolLanguage (default: 'auto')
|
||||
*
|
||||
* Changes are saved server-side through the project settings context and
|
||||
* synced to other clients over the project socket. The CodeMirror
|
||||
* extension is informed via the `lt:settings-changed` window event so this
|
||||
* component does not need to be inside the CodeMirrorViewContext.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useProjectSettingsContext } from '@/features/ide-settings/context/project-settings-context'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import DropdownSetting from '@/features/ide-settings/components/dropdown-setting'
|
||||
import ToggleSetting from '@/features/ide-settings/components/toggle-setting'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
interface LTLanguage {
|
||||
name: string
|
||||
code: string
|
||||
longCode: string
|
||||
}
|
||||
|
||||
export default function LanguageToolSettings() {
|
||||
const {
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
} = useProjectSettingsContext()
|
||||
|
||||
// Defaults: enabled + picky (picky level is the recommended default).
|
||||
const enabled = languageToolEnabled !== false
|
||||
const picky = languageToolPicky !== false
|
||||
const language = languageToolLanguage || 'auto'
|
||||
|
||||
const gtAvailable =
|
||||
getMeta('ol-ExposedSettings')?.languageToolAvailable === true
|
||||
|
||||
const [languages, setLanguages] = useState<LTLanguage[]>([])
|
||||
const [languagesLoading, setLanguagesLoading] = useState(false)
|
||||
|
||||
// ── Load language list when LT is available and grammar check is on ──
|
||||
useEffect(() => {
|
||||
if (!gtAvailable || !enabled || languages.length > 0) return
|
||||
|
||||
setLanguagesLoading(true)
|
||||
getJSON<LTLanguage[]>('/languagetool/languages')
|
||||
.then(langs => {
|
||||
// Sort alphabetically by display name for usability
|
||||
const sorted = [...langs].sort((a, b) => a.name.localeCompare(b.name))
|
||||
setLanguages(sorted)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLanguagesLoading(false))
|
||||
}, [gtAvailable, enabled, languages.length])
|
||||
|
||||
// ── Notify the CodeMirror extension of the effective settings ─────────
|
||||
const notify = useCallback(
|
||||
(detail: { enabled: boolean; picky: boolean; language: string }) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('lt:settings-changed', {
|
||||
detail: { ...detail, available: gtAvailable },
|
||||
})
|
||||
)
|
||||
},
|
||||
[gtAvailable]
|
||||
)
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
(newEnabled: boolean) => {
|
||||
setLanguageToolEnabled(newEnabled)
|
||||
notify({ enabled: newEnabled, picky, language })
|
||||
},
|
||||
[setLanguageToolEnabled, picky, language, notify]
|
||||
)
|
||||
|
||||
const handleTogglePicky = useCallback(
|
||||
(newPicky: boolean) => {
|
||||
setLanguageToolPicky(newPicky)
|
||||
notify({ enabled, picky: newPicky, language })
|
||||
},
|
||||
[setLanguageToolPicky, enabled, language, notify]
|
||||
)
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(newLanguage: string) => {
|
||||
setLanguageToolLanguage(newLanguage || 'auto')
|
||||
notify({ enabled, picky, language: newLanguage || 'auto' })
|
||||
},
|
||||
[setLanguageToolLanguage, enabled, picky, notify]
|
||||
)
|
||||
|
||||
// Don't render anything if LT is not configured on the server
|
||||
if (!gtAvailable) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToggleSetting
|
||||
id="languagetool-enabled"
|
||||
label="LanguageTool grammar check"
|
||||
description="Grammar and style checking powered by LanguageTool"
|
||||
checked={enabled}
|
||||
onChange={handleToggleEnabled}
|
||||
/>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<ToggleSetting
|
||||
id="languagetool-picky"
|
||||
label="Picky mode"
|
||||
description="Picky level catches more (subtle) grammar and style issues; off uses LanguageTool's default level"
|
||||
checked={picky}
|
||||
onChange={handleTogglePicky}
|
||||
/>
|
||||
|
||||
<DropdownSetting
|
||||
id="languagetool-language"
|
||||
label="Language"
|
||||
options={[{ value: 'auto', label: 'Auto-detect' }]}
|
||||
optgroups={
|
||||
languages.length > 0
|
||||
? [
|
||||
{
|
||||
label: 'Languages',
|
||||
options: languages.map(l => ({
|
||||
value: l.longCode,
|
||||
label: l.name,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
onChange={handleLanguageChange}
|
||||
value={language}
|
||||
width="wide"
|
||||
loading={languagesLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* LanguageTool CodeMirror 6 extension.
|
||||
*
|
||||
* Exported as a named `extension` function matching the `sourceEditorExtensions`
|
||||
* module-import slot signature: (options: Record<string, any>) => Extension
|
||||
*
|
||||
* Features:
|
||||
* - Grammar and style linting via the /languagetool/check backend proxy
|
||||
* - Uses a CUSTOM decoration + tooltip system instead of @codemirror/lint.
|
||||
* The Overleaf compile-log linter globally combines markerFilter (only
|
||||
* severity=error) and tooltipFilter (returns []) into the lint facet,
|
||||
* which hides LT grammar warnings. By managing our own StateField,
|
||||
* Decoration layer, and hoverTooltip we bypass those filters entirely.
|
||||
* - Debounced: checks 2 s after the last document change
|
||||
* - LaTeX-aware: uses the Lezer syntax tree to identify plain-text regions;
|
||||
* math, commands, comments, citation keys, labels, and package arguments
|
||||
* are sent as `markup` in the LT annotation format
|
||||
* - Falls back to regex-based parsing when the syntax tree is unavailable
|
||||
* - Filters TYPOS category when the Overleaf Hunspell spellchecker is active
|
||||
* to avoid duplicate underlines; tracks Hunspell state dynamically
|
||||
* - Cancels stale HTTP requests with AbortController
|
||||
* - Project-wide settings: `enabled` / `picky` / `language` arrive through
|
||||
* `options.languageTool` (from the project settings, default: enabled,
|
||||
* picky ON → level=picky) and live `lt:settings-changed` window events
|
||||
* dispatched by the settings UI / the source-editor scope.
|
||||
* - Theme aware: tooltip and underlines use the Overleaf themed CSS
|
||||
* variables (--*-themed) with light-mode fallbacks, so they render
|
||||
* correctly in both the dark and the light theme.
|
||||
*
|
||||
* All logic is self-contained — no imports from Overleaf core internals.
|
||||
*/
|
||||
|
||||
import {
|
||||
StateEffect,
|
||||
StateField,
|
||||
type EditorState,
|
||||
type Extension,
|
||||
} from '@codemirror/state'
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
hoverTooltip,
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view'
|
||||
import { syntaxTree, ensureSyntaxTree } from '@codemirror/language'
|
||||
import type { SyntaxNodeRef } from '@lezer/common'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
latexToAnnotations,
|
||||
type LTAnnotation,
|
||||
} from './utils/latex-to-annotations'
|
||||
|
||||
// ─── LT types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface LTMatch {
|
||||
offset: number
|
||||
length: number
|
||||
message: string
|
||||
rule?: { id?: string; category?: { id?: string } }
|
||||
replacements?: Array<{ value: string }>
|
||||
}
|
||||
|
||||
/** Our own diagnostic type — independent of @codemirror/lint's Diagnostic. */
|
||||
interface LTDiagnostic {
|
||||
from: number
|
||||
to: number
|
||||
severity: 'error' | 'warning'
|
||||
message: string
|
||||
source: string
|
||||
replacements: string[]
|
||||
}
|
||||
|
||||
/** Effective per-project settings (see languagetool-settings.tsx). */
|
||||
interface LTSettings {
|
||||
available: boolean
|
||||
enabled: boolean
|
||||
picky: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: LTSettings = {
|
||||
available: false,
|
||||
enabled: true,
|
||||
picky: true,
|
||||
language: 'auto',
|
||||
}
|
||||
|
||||
// ─── LaTeX node skip-list ─────────────────────────────────────────────────────
|
||||
// Mirrors the `noSpellCheckProp` definitions in the Overleaf LaTeX grammar
|
||||
// without importing the core module. These argument node types contain
|
||||
// non-prose content (citation keys, labels, package names, etc.) and must be
|
||||
// skipped so that LanguageTool does not report false positives on them.
|
||||
|
||||
/** Node types whose entire subtree is always non-prose. */
|
||||
const ALWAYS_SKIP = new Set([
|
||||
'BibKeyArgument',
|
||||
'BibliographyArgument',
|
||||
'BibliographyStyleArgument',
|
||||
'DocumentClassArgument',
|
||||
'LabelArgument',
|
||||
'PackageArgument',
|
||||
'RefArgument',
|
||||
])
|
||||
|
||||
/** Node types to skip only when they appear inside certain parent contexts. */
|
||||
const CONTEXTUAL_SKIP: Record<string, string[][]> = {
|
||||
OptionalArgument: [
|
||||
['DocumentClass'],
|
||||
['IncludeGraphics'],
|
||||
['LineBreak'],
|
||||
['UsePackage'],
|
||||
['FigureEnvironment', 'BeginEnv'],
|
||||
['ListEnvironment', 'BeginEnv'],
|
||||
],
|
||||
ShortTextArgument: [['Date'], ['SetLengthCommand']],
|
||||
TextArgument: [['TabularEnvironment', 'BeginEnv']],
|
||||
}
|
||||
|
||||
function shouldSkipNode(node: SyntaxNodeRef): boolean {
|
||||
if (ALWAYS_SKIP.has(node.type.name)) return true
|
||||
const contexts = CONTEXTUAL_SKIP[node.type.name]
|
||||
if (contexts) {
|
||||
return contexts.some(ctx => node.matchContext(ctx))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Syntax-tree walker ───────────────────────────────────────────────────────
|
||||
|
||||
interface TextSpan {
|
||||
from: number
|
||||
to: number
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all "Normal" text spans from a single document line using the Lezer
|
||||
* syntax tree. Nodes matching the skip-list above are pruned so their
|
||||
* children (including any nested Normal nodes) are never visited.
|
||||
*/
|
||||
function getNormalSpansFromLine(
|
||||
state: EditorState,
|
||||
lineFrom: number,
|
||||
lineTo: number
|
||||
): TextSpan[] {
|
||||
const tree = syntaxTree(state)
|
||||
const spans: TextSpan[] = []
|
||||
|
||||
tree.iterate({
|
||||
from: lineFrom,
|
||||
to: lineTo,
|
||||
enter(node: SyntaxNodeRef) {
|
||||
if (shouldSkipNode(node)) return false
|
||||
if (node.type.name === 'Normal') {
|
||||
spans.push({
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
text: state.doc.sliceString(node.from, node.to),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
// ─── Annotation builder (syntax tree) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit a gap (everything between two Normal text spans) as whitespace → text
|
||||
* (so LanguageTool sees sentence boundaries) and non-whitespace → markup.
|
||||
*/
|
||||
function emitGap(annotations: LTAnnotation[], gap: string): void {
|
||||
const segments = gap.match(/\s+|\S+/g)
|
||||
if (!segments) return
|
||||
for (const segment of segments) {
|
||||
if (/^\s+$/.test(segment)) {
|
||||
annotations.push({ text: segment })
|
||||
} else {
|
||||
annotations.push({ markup: segment })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LT annotations from the Lezer syntax tree. Only "Normal" text nodes
|
||||
* are emitted as checkable `text`; everything else becomes `markup`.
|
||||
*
|
||||
* Invariant: annotations.map(a => a.text ?? a.markup).join('') === source
|
||||
*/
|
||||
function buildAnnotationsFromTree(view: EditorView): LTAnnotation[] {
|
||||
const { state } = view
|
||||
const doc = state.doc
|
||||
const annotations: LTAnnotation[] = []
|
||||
let lastEnd = 0
|
||||
|
||||
for (let i = 1; i <= doc.lines; i++) {
|
||||
const line = doc.line(i)
|
||||
const spans = getNormalSpansFromLine(state, line.from, line.to)
|
||||
|
||||
for (const span of spans) {
|
||||
if (span.from > lastEnd) {
|
||||
emitGap(annotations, doc.sliceString(lastEnd, span.from))
|
||||
}
|
||||
annotations.push({ text: span.text })
|
||||
lastEnd = span.to
|
||||
}
|
||||
}
|
||||
|
||||
if (lastEnd < doc.length) {
|
||||
emitGap(annotations, doc.sliceString(lastEnd, doc.length))
|
||||
}
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
// ─── State management ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// We deliberately do NOT use @codemirror/lint's `linter()` because its
|
||||
// `lintConfig` facet is a global combine: the compile-log linter's
|
||||
// `markerFilter` (only severity=error) and `tooltipFilter` (returns [])
|
||||
// suppress our grammar-warning underlines and tooltips. Instead we manage
|
||||
// our own StateField → DecorationSet pipeline and a standalone hoverTooltip.
|
||||
|
||||
const setLTDiagnosticsEffect = StateEffect.define<readonly LTDiagnostic[]>()
|
||||
|
||||
const ltDiagnosticsField = StateField.define<{
|
||||
diagnostics: readonly LTDiagnostic[]
|
||||
decorations: DecorationSet
|
||||
}>({
|
||||
create() {
|
||||
return { diagnostics: [], decorations: Decoration.none }
|
||||
},
|
||||
update({ diagnostics, decorations }, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setLTDiagnosticsEffect)) {
|
||||
const diags = effect.value
|
||||
if (diags.length === 0) {
|
||||
return { diagnostics: [], decorations: Decoration.none }
|
||||
}
|
||||
const marks = diags
|
||||
.filter(d => d.from < d.to && d.to <= tr.state.doc.length)
|
||||
.map(d =>
|
||||
Decoration.mark({
|
||||
class: `cm-lt-underline cm-lt-underline-${d.severity}`,
|
||||
}).range(d.from, d.to)
|
||||
)
|
||||
return {
|
||||
diagnostics: diags,
|
||||
decorations: Decoration.set(marks, true),
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tr.docChanged) {
|
||||
const mapped = diagnostics
|
||||
.map(d => ({
|
||||
...d,
|
||||
from: tr.changes.mapPos(d.from, 1),
|
||||
to: tr.changes.mapPos(d.to, -1),
|
||||
}))
|
||||
.filter(d => d.from < d.to)
|
||||
return {
|
||||
diagnostics: mapped,
|
||||
decorations: decorations.map(tr.changes),
|
||||
}
|
||||
}
|
||||
return { diagnostics, decorations }
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f, val => val.decorations),
|
||||
})
|
||||
|
||||
// ─── Hover tooltip ────────────────────────────────────────────────────────────
|
||||
|
||||
const ltTooltip = hoverTooltip(
|
||||
(view, pos) => {
|
||||
const { diagnostics } = view.state.field(ltDiagnosticsField)
|
||||
const matches = diagnostics.filter(d => d.from <= pos && pos <= d.to)
|
||||
if (!matches.length) return null
|
||||
|
||||
const primary = matches[0]
|
||||
return {
|
||||
pos: primary.from,
|
||||
end: primary.to,
|
||||
above: true,
|
||||
// NB: this closure uses the guard's `view` (EditorView) — do NOT
|
||||
// shadow it, hoverTooltip passes the editor *state* to create().
|
||||
create() {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'cm-lt-tooltip'
|
||||
|
||||
for (const match of matches) {
|
||||
const entry = document.createElement('div')
|
||||
entry.className = 'cm-lt-tooltip-entry'
|
||||
|
||||
const msg = document.createElement('div')
|
||||
msg.className = 'cm-lt-tooltip-message'
|
||||
msg.textContent = match.message
|
||||
entry.appendChild(msg)
|
||||
|
||||
if (match.source) {
|
||||
const src = document.createElement('div')
|
||||
src.className = 'cm-lt-tooltip-source'
|
||||
src.textContent = match.source
|
||||
entry.appendChild(src)
|
||||
}
|
||||
|
||||
if (match.replacements.length) {
|
||||
const actions = document.createElement('div')
|
||||
actions.className = 'cm-lt-tooltip-actions'
|
||||
for (const rep of match.replacements) {
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 'cm-lt-tooltip-action'
|
||||
btn.textContent = rep
|
||||
btn.addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
view.dispatch({
|
||||
changes: { from: match.from, to: match.to, insert: rep },
|
||||
})
|
||||
})
|
||||
actions.appendChild(btn)
|
||||
}
|
||||
entry.appendChild(actions)
|
||||
}
|
||||
|
||||
dom.appendChild(entry)
|
||||
}
|
||||
|
||||
return { dom }
|
||||
},
|
||||
}
|
||||
},
|
||||
{ hoverTime: 300 }
|
||||
)
|
||||
|
||||
// ─── Theme ────────────────────────────────────────────────────────────────────
|
||||
// Overleaf 6.x themes via body[data-theme] → semantic CSS variables
|
||||
// (--bg-primary-themed & friends, see
|
||||
// stylesheets/abstracts/themes-common-variables.scss). The light fallbacks
|
||||
// in var(...) keep the extension usable where the variables are undefined.
|
||||
|
||||
const ltTheme = EditorView.baseTheme({
|
||||
'.cm-lt-underline': {
|
||||
backgroundImage: 'none !important',
|
||||
textDecoration: 'underline wavy',
|
||||
textUnderlineOffset: '3px',
|
||||
},
|
||||
'.cm-lt-underline-warning': {
|
||||
textDecorationColor: 'var(--content-warning-themed, #E8A200)',
|
||||
},
|
||||
'.cm-lt-underline-error': {
|
||||
textDecorationColor: 'var(--content-danger-themed, #ff5252)',
|
||||
},
|
||||
'.cm-lt-tooltip': {
|
||||
color: 'var(--content-primary-themed, #333)',
|
||||
backgroundColor: 'var(--bg-primary-themed, #fff)',
|
||||
border: '1px solid var(--border-divider-themed, #ddd)',
|
||||
borderRadius: '4px',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
},
|
||||
'.cm-lt-tooltip-entry + .cm-lt-tooltip-entry': {
|
||||
marginTop: '8px',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid var(--border-divider-themed, #eee)',
|
||||
},
|
||||
'.cm-lt-tooltip-message': {
|
||||
marginBottom: '4px',
|
||||
},
|
||||
'.cm-lt-tooltip-source': {
|
||||
fontSize: '11px',
|
||||
color: 'var(--content-secondary-themed, #888)',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
'.cm-lt-tooltip-actions': {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
},
|
||||
'.cm-lt-tooltip-action': {
|
||||
padding: '2px 8px',
|
||||
border: '1px solid var(--border-divider-themed, #ccc)',
|
||||
borderRadius: '3px',
|
||||
backgroundColor: 'var(--bg-secondary-themed, #f5f5f5)',
|
||||
color: 'var(--content-primary-themed, #333)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--bg-tertiary-themed, #e0e0e0)',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Module export ────────────────────────────────────────────────────────────
|
||||
|
||||
export const extension = (options: Record<string, any>): Extension => {
|
||||
/**
|
||||
* ViewPlugin that manages the full lifecycle:
|
||||
* 1. On mount — starts disabled until the first `lt:settings-changed`
|
||||
* event (dispatched by the source-editor scope with the project
|
||||
* settings) or the options snapshot confirms LT is available and
|
||||
* enabled for this project.
|
||||
* 2. On document changes — debounces a new check (2 s after last edit).
|
||||
* 3. Listens to `lt:settings-changed` window events carrying the
|
||||
* per-project `{ enabled, picky, language }` settings.
|
||||
* 4. Tracks Hunspell state dynamically via duck-typing on
|
||||
* setSpellCheckLanguageEffect payloads.
|
||||
* 5. Dispatches setLTDiagnosticsEffect to update the StateField, which
|
||||
* in turn drives the decoration layer and hover tooltip.
|
||||
*/
|
||||
const syncPlugin = ViewPlugin.define(view => {
|
||||
const initial: LTSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(options?.languageTool ?? {}),
|
||||
}
|
||||
let ltServerAvailable = !!initial.available
|
||||
let hunspellActive = !!options?.spelling?.spellCheckLanguage
|
||||
let currentLanguage = initial.language || 'auto'
|
||||
let picky = initial.picky !== false
|
||||
let enabled = !!initial.available && (initial.enabled !== false)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
|
||||
// ── Scheduling ──────────────────────────────────────────────────────
|
||||
|
||||
function scheduleCheck(delay = 2000) {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (!enabled) return
|
||||
timer = setTimeout(() => runCheck(), delay)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = null
|
||||
if (controller) controller.abort()
|
||||
controller = null
|
||||
view.dispatch({ effects: setLTDiagnosticsEffect.of([]) })
|
||||
}
|
||||
|
||||
// ── Core check logic ────────────────────────────────────────────────
|
||||
|
||||
async function runCheck() {
|
||||
if (!enabled) return
|
||||
|
||||
// Abort any in-flight request.
|
||||
if (controller) controller.abort()
|
||||
const ac = new AbortController()
|
||||
controller = ac
|
||||
|
||||
const source = view.state.doc.toString()
|
||||
if (source.trim().length < 20) {
|
||||
if (!ac.signal.aborted) {
|
||||
view.dispatch({ effects: setLTDiagnosticsEffect.of([]) })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the Lezer syntax tree; fall back to regex-based parser.
|
||||
let annotations: LTAnnotation[]
|
||||
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 500)
|
||||
if (tree) {
|
||||
annotations = buildAnnotationsFromTree(view)
|
||||
} else {
|
||||
annotations = latexToAnnotations(source)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/languagetool/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': getMeta('ol-csrfToken') },
|
||||
body: JSON.stringify({
|
||||
language: currentLanguage,
|
||||
// picky mode ON (the default) → level=picky; OFF → level=default
|
||||
level: picky ? 'picky' : 'default',
|
||||
data: { annotation: annotations },
|
||||
}),
|
||||
signal: ac.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const result: { matches: LTMatch[] } = await response.json()
|
||||
|
||||
// Filter TYPOS when Hunspell is active (it handles typos better).
|
||||
let matches = result.matches
|
||||
if (hunspellActive) {
|
||||
matches = matches.filter(m => m.rule?.category?.id !== 'TYPOS')
|
||||
}
|
||||
|
||||
const docLen = view.state.doc.length
|
||||
const diagnostics: LTDiagnostic[] = matches
|
||||
.filter(m => m.offset >= 0 && m.offset + m.length <= docLen)
|
||||
.map(m => ({
|
||||
from: m.offset,
|
||||
to: m.offset + m.length,
|
||||
severity:
|
||||
m.rule?.category?.id === 'TYPOS'
|
||||
? ('error' as const)
|
||||
: ('warning' as const),
|
||||
message: m.message,
|
||||
source: `LanguageTool (${m.rule?.id ?? ''})${
|
||||
picky ? ' (picky)' : ''
|
||||
}`,
|
||||
replacements: (m.replacements ?? []).slice(0, 5).map(r => r.value),
|
||||
}))
|
||||
|
||||
if (!ac.signal.aborted) {
|
||||
view.dispatch({ effects: setLTDiagnosticsEffect.of(diagnostics) })
|
||||
}
|
||||
} catch {
|
||||
// AbortError or network failure — silently ignore.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings-changed listener ───────────────────────────────────────
|
||||
//
|
||||
// Detail shape (from languagetool-settings.tsx / use-codemirror-scope):
|
||||
// { available?: boolean, enabled?: boolean, picky?: boolean, language?: string }
|
||||
|
||||
const applySettings = (detail: Partial<LTSettings>) => {
|
||||
if (detail.available != null) ltServerAvailable = !!detail.available
|
||||
const newLanguage = detail.language || currentLanguage || 'auto'
|
||||
const newPicky = detail.picky !== false
|
||||
const newEnabled = ltServerAvailable && (detail.enabled !== false)
|
||||
|
||||
const changed =
|
||||
newEnabled !== enabled || newPicky !== picky || newLanguage !== currentLanguage
|
||||
enabled = newEnabled
|
||||
picky = newPicky
|
||||
currentLanguage = newLanguage
|
||||
if (!enabled) {
|
||||
clearAll()
|
||||
} else if (changed) {
|
||||
scheduleCheck(100)
|
||||
}
|
||||
}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<Partial<LTSettings>>).detail
|
||||
if (!detail) return
|
||||
applySettings(detail)
|
||||
}
|
||||
|
||||
window.addEventListener('lt:settings-changed', handler)
|
||||
|
||||
// ── Initialisation ──────────────────────────────────────────────────
|
||||
if (enabled) {
|
||||
scheduleCheck(500)
|
||||
}
|
||||
|
||||
// ── ViewPlugin callbacks ────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged && enabled) {
|
||||
// Abort stale in-flight request and schedule a fresh check.
|
||||
if (controller) controller.abort()
|
||||
scheduleCheck()
|
||||
}
|
||||
|
||||
// Track Hunspell state changes (duck-typed from effects).
|
||||
for (const tr of update.transactions) {
|
||||
for (const effect of tr.effects) {
|
||||
const val = effect.value as Record<string, unknown> | null
|
||||
if (
|
||||
val !== null &&
|
||||
typeof val === 'object' &&
|
||||
'spellCheckLanguage' in val
|
||||
) {
|
||||
const newActive = !!(val.spellCheckLanguage as string)
|
||||
if (newActive !== hunspellActive) {
|
||||
hunspellActive = newActive
|
||||
if (enabled) scheduleCheck(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('lt:settings-changed', handler)
|
||||
if (timer) clearTimeout(timer)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
controller = null
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return [ltDiagnosticsField, ltTooltip, ltTheme, syncPlugin]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* LanguageTool settings section hook — registers the LanguageTool settings in
|
||||
* the Settings modal (Spelling and Language tab) through the
|
||||
* `settingsModalSpellcheckSections` slot.
|
||||
*
|
||||
* Signature required by the slot consumer: a default-exported function that
|
||||
* returns a SettingsSection (or null).
|
||||
*/
|
||||
|
||||
import type { SettingsSection } from '@/features/ide-settings/context/types'
|
||||
import LanguageToolSettings from './components/languagetool-settings'
|
||||
|
||||
export default function languageToolSection(): SettingsSection {
|
||||
return {
|
||||
key: 'languagetool',
|
||||
title: 'LanguageTool',
|
||||
settings: [
|
||||
{
|
||||
key: 'languagetool-settings',
|
||||
component: <LanguageToolSettings />,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Converts LaTeX source to a LanguageTool annotation array.
|
||||
*
|
||||
* The invariant is:
|
||||
* annotations.map(a => a.text ?? a.markup).join('') === source
|
||||
*
|
||||
* This means that the character offsets returned by LanguageTool (which count
|
||||
* both text and markup characters) map directly to positions in the original
|
||||
* CodeMirror document — no offset remapping is needed.
|
||||
*
|
||||
* LaTeX commands, math environments, and comments are emitted as `markup`.
|
||||
* Regular text content is emitted as `text` and will be grammar-checked.
|
||||
*/
|
||||
|
||||
export interface LTAnnotation {
|
||||
text?: string
|
||||
markup?: string
|
||||
}
|
||||
|
||||
/** Math environments whose entire content should be treated as markup. */
|
||||
const MATH_ENVS = new Set([
|
||||
'equation',
|
||||
'equation*',
|
||||
'align',
|
||||
'align*',
|
||||
'alignat',
|
||||
'alignat*',
|
||||
'gather',
|
||||
'gather*',
|
||||
'multline',
|
||||
'multline*',
|
||||
'displaymath',
|
||||
'eqnarray',
|
||||
'eqnarray*',
|
||||
'flalign',
|
||||
'flalign*',
|
||||
'subequations',
|
||||
'math',
|
||||
'dmath',
|
||||
'dgroup',
|
||||
'dgroup*',
|
||||
])
|
||||
|
||||
export function latexToAnnotations(source: string): LTAnnotation[] {
|
||||
const annotations: LTAnnotation[] = []
|
||||
let textStart = 0 // start of the current un-emitted text segment
|
||||
let i = 0
|
||||
|
||||
/** Emit accumulated plain text up to `end`. */
|
||||
function flushText(end: number): void {
|
||||
if (end > textStart) {
|
||||
annotations.push({ text: source.slice(textStart, end) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `source[start..end)` as markup, flushing any buffered text first.
|
||||
* Also advances `textStart` to `end`.
|
||||
*/
|
||||
function addMarkup(start: number, end: number): void {
|
||||
flushText(start)
|
||||
if (end > start) {
|
||||
annotations.push({ markup: source.slice(start, end) })
|
||||
}
|
||||
textStart = end
|
||||
}
|
||||
|
||||
while (i < source.length) {
|
||||
const ch = source[i]
|
||||
|
||||
// ── LaTeX comments: % … (end of line) ──────────────────────────────────
|
||||
if (ch === '%') {
|
||||
let end = i + 1
|
||||
while (end < source.length && source[end] !== '\n') end++
|
||||
addMarkup(i, end)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Display math: $$…$$ ────────────────────────────────────────────────
|
||||
if (ch === '$' && i + 1 < source.length && source[i + 1] === '$') {
|
||||
const start = i
|
||||
let j = i + 2
|
||||
while (j + 1 < source.length) {
|
||||
if (source[j] === '$' && source[j + 1] === '$') {
|
||||
j += 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
addMarkup(start, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Inline math: $…$ ───────────────────────────────────────────────────
|
||||
if (ch === '$') {
|
||||
const start = i
|
||||
let j = i + 1
|
||||
while (j < source.length && source[j] !== '$') {
|
||||
if (source[j] === '\\') j++ // skip one escaped character
|
||||
j++
|
||||
}
|
||||
const end = j < source.length ? j + 1 : j
|
||||
addMarkup(start, end)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// ── LaTeX commands ─────────────────────────────────────────────────────
|
||||
if (ch === '\\' && i + 1 < source.length) {
|
||||
const next = source[i + 1]
|
||||
|
||||
// \[…\] (display math)
|
||||
if (next === '[') {
|
||||
const start = i
|
||||
let j = i + 2
|
||||
while (j + 1 < source.length) {
|
||||
if (source[j] === '\\' && source[j + 1] === ']') {
|
||||
j += 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
addMarkup(start, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// \(…\) (inline math)
|
||||
if (next === '(') {
|
||||
const start = i
|
||||
let j = i + 2
|
||||
while (j + 1 < source.length) {
|
||||
if (source[j] === '\\' && source[j + 1] === ')') {
|
||||
j += 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
addMarkup(start, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// \begin{envname}
|
||||
if (source.startsWith('begin{', i + 1)) {
|
||||
const closeBrace = source.indexOf('}', i + 7)
|
||||
if (closeBrace !== -1) {
|
||||
const envName = source.slice(i + 7, closeBrace)
|
||||
|
||||
if (MATH_ENVS.has(envName)) {
|
||||
// Entire math environment is markup
|
||||
const endTag = `\\end{${envName}}`
|
||||
const endIdx = source.indexOf(endTag, closeBrace + 1)
|
||||
const envEnd =
|
||||
endIdx !== -1 ? endIdx + endTag.length : source.length
|
||||
addMarkup(i, envEnd)
|
||||
i = envEnd
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-math environment: only mark up the \begin{...} tag itself
|
||||
addMarkup(i, closeBrace + 1)
|
||||
i = closeBrace + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// \end{…}
|
||||
if (source.startsWith('end{', i + 1)) {
|
||||
const closeBrace = source.indexOf('}', i + 5)
|
||||
const end = closeBrace !== -1 ? closeBrace + 1 : i + 5
|
||||
addMarkup(i, end)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// General alpha command: \commandname (optional trailing whitespace)
|
||||
if (/[a-zA-Z]/.test(next)) {
|
||||
let j = i + 1
|
||||
while (j < source.length && /[a-zA-Z]/.test(source[j])) j++
|
||||
// Absorb trailing horizontal whitespace into the markup token
|
||||
while (j < source.length && (source[j] === ' ' || source[j] === '\t'))
|
||||
j++
|
||||
addMarkup(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// Single non-letter command: \\, \%, \$, \{, \}, \&, \_, \#, etc.
|
||||
addMarkup(i, i + 2)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Curly braces (LaTeX grouping — always markup) ──────────────────────
|
||||
if (ch === '{' || ch === '}') {
|
||||
addMarkup(i, i + 1)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Regular text character ─────────────────────────────────────────────
|
||||
i++
|
||||
}
|
||||
|
||||
flushText(source.length)
|
||||
return annotations
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
|
||||
let LanguageToolModule = {}
|
||||
if (process.env.LANGUAGE_TOOL_ENABLED?.toLowerCase() === 'true') {
|
||||
logger.debug({}, 'Enable LanguageTool module')
|
||||
|
||||
Settings.languageToolURL = process.env.LANGUAGE_TOOL_URL ||
|
||||
`http://${process.env.LANGUAGE_TOOL_HOST || 'languagetool'}:${process.env.LANGUAGE_TOOL_PORT || '8010'}`
|
||||
|
||||
const { default: LanguageToolRouter } = await import('./app/src/LanguageToolRouter.mjs')
|
||||
|
||||
LanguageToolModule = {
|
||||
router: LanguageToolRouter,
|
||||
}
|
||||
}
|
||||
|
||||
export default LanguageToolModule
|
||||
@@ -96,7 +96,9 @@ 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)
|
||||
// NOTE: createZipStreamForMultipleProjects(projectIds, zipFromHistory, callback) has no
|
||||
// historyId slot (unlike createZipStreamForProject), so only one argument besides the ids.
|
||||
|
||||
zipStream.on('progress', progress => {
|
||||
updateProgress(progress.entries.total, allIds.length)
|
||||
@@ -132,7 +134,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)
|
||||
}
|
||||
|
||||
@@ -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,18 @@
|
||||
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
|
||||
|
||||
const { Schema } = mongoose
|
||||
const { ObjectId } = Schema
|
||||
|
||||
export const WakaTimeUserCredentialsSchema = new Schema(
|
||||
{
|
||||
userId: { type: ObjectId, ref: 'User', required: true, unique: true },
|
||||
apiUrl: { type: String, required: true },
|
||||
apiKeyEncrypted: { type: Schema.Types.Mixed, required: true },
|
||||
},
|
||||
{ collection: 'wakaTimeUserCredentials', minimize: false }
|
||||
)
|
||||
|
||||
export const WakaTimeUserCredentials = mongoose.model(
|
||||
'WakaTimeUserCredentials',
|
||||
WakaTimeUserCredentialsSchema,
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import Path from 'node:path'
|
||||
import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor'
|
||||
|
||||
const TOKEN_CIPHER_FILE = '/var/lib/overleaf/data/.wakatime-token-cipher.json'
|
||||
const TOKEN_CIPHER_LABEL = 'OL_CEP_wakatime-v3'
|
||||
|
||||
let encryptorInstance = null
|
||||
|
||||
/**
|
||||
* Get or create a stable encryptor data that persists across container
|
||||
* recreations. Priority:
|
||||
* 1. WAKATIME_TOKEN_CIPHER_PASSWORD / TOKEN_CIPHER_PASSWORD env var
|
||||
* 2. JSON file with encryptor data in the persistent volume (/var/lib/overleaf/data/)
|
||||
* — auto-generated on first use, survives container rebuilds
|
||||
*/
|
||||
function _getEncryptorData() {
|
||||
const cipherPassword = process.env.WAKATIME_TOKEN_CIPHER_PASSWORD || process.env.TOKEN_CIPHER_PASSWORD
|
||||
const cipherLabel = process.env.WAKATIME_TOKEN_CIPHER_LABEL ||
|
||||
process.env.TOKEN_CIPHER_LABEL ||
|
||||
TOKEN_CIPHER_LABEL
|
||||
if (cipherPassword) {
|
||||
return {
|
||||
cipherLabel,
|
||||
cipherPasswords: {
|
||||
[cipherLabel]: cipherPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
// cipherPassword is not set, use file
|
||||
const cipherFile = process.env.WAKATIME_TOKEN_CIPHER_FILE ||
|
||||
process.env.TOKEN_CIPHER_FILE ||
|
||||
TOKEN_CIPHER_FILE
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(cipherFile, 'utf8'))
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
logger.info({ cipherFile }, 'Creating new cipherFile')
|
||||
} else {
|
||||
logger.error({ err, cipherFile }, 'Bad cipherFile')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// File doesn't exist yet — generate one
|
||||
try {
|
||||
const encryptorData = {
|
||||
cipherLabel,
|
||||
cipherPasswords: {
|
||||
[cipherLabel]: crypto.randomBytes(32).toString('base64')
|
||||
},
|
||||
}
|
||||
|
||||
const dir = Path.dirname(cipherFile)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(cipherFile, JSON.stringify(encryptorData, null, 2), { mode: 0o600 })
|
||||
return encryptorData
|
||||
|
||||
} catch (err) {
|
||||
logger.error({ err, cipherFile }, 'Cannot create cipherFile')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function _getEncryptor() {
|
||||
if (!encryptorInstance) {
|
||||
const encryptorData = _getEncryptorData()
|
||||
encryptorInstance = new AccessTokenEncryptorClass(encryptorData)
|
||||
}
|
||||
return encryptorInstance
|
||||
}
|
||||
|
||||
export const AccessTokenEncryptor = {
|
||||
async encryptJson(json) {
|
||||
return await _getEncryptor().promises.encryptJson(json)
|
||||
},
|
||||
async decryptToJson(encryptedJson) {
|
||||
return await _getEncryptor().promises.decryptToJson(encryptedJson)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs'
|
||||
import { WakaTimeUserCredentials } from '../models/wakaTimeUserCredentials.mjs'
|
||||
import { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.mjs'
|
||||
|
||||
const { normalizeQuery } = Mongo
|
||||
|
||||
/**
|
||||
* Decrypt stored WakaTime credentials for a user.
|
||||
* Returns { apiUrl, apiKey } or null if not linked.
|
||||
*/
|
||||
async function getCredentials(userId) {
|
||||
const record = await WakaTimeUserCredentials.findOne(
|
||||
normalizeQuery({ userId })
|
||||
).exec()
|
||||
|
||||
if (!record?.apiKeyEncrypted) return null
|
||||
|
||||
try {
|
||||
const apiKey = await AccessTokenEncryptor.decryptToJson(
|
||||
record.apiKeyEncrypted
|
||||
)
|
||||
return { apiUrl: record.apiUrl, apiKey }
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ userId, err },
|
||||
'failed to decrypt WakaTime credentials, treating as not connected'
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a WakaTime (or Wakapi-compatible) account.
|
||||
*/
|
||||
async function storeCredentials(userId, apiUrl, apiKey) {
|
||||
const apiKeyEncrypted = await AccessTokenEncryptor.encryptJson(apiKey)
|
||||
await WakaTimeUserCredentials.findOneAndUpdate(
|
||||
normalizeQuery({ userId }),
|
||||
{ $set: { apiUrl, apiKeyEncrypted } },
|
||||
{ upsert: true }
|
||||
).exec()
|
||||
}
|
||||
|
||||
async function removeCredentials(userId) {
|
||||
await WakaTimeUserCredentials.deleteOne(normalizeQuery({ userId })).exec()
|
||||
}
|
||||
|
||||
export default {
|
||||
getCredentials,
|
||||
storeCredentials,
|
||||
removeCredentials,
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import Settings from '@overleaf/settings'
|
||||
import {
|
||||
fetchNothing,
|
||||
fetchJson,
|
||||
RequestFailedError,
|
||||
} from '@overleaf/fetch-utils'
|
||||
import {
|
||||
NotFoundError,
|
||||
TooManyRequestsError,
|
||||
ServiceNotConfiguredError,
|
||||
ForbiddenError,
|
||||
} from '../../../../app/src/Features/Errors/Errors.js'
|
||||
import TokenManager from './TokenManager.mjs'
|
||||
|
||||
const DEFAULT_API_URL = 'https://wakatime.com/api/v1'
|
||||
const REQUEST_TIMEOUT_MS = 15 * 1000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deployment-managed identity (tools/wakapi_server_compose, trusted-header)
|
||||
// ---------------------------------------------------------------------------
|
||||
// When this Overleaf deployment runs its own Wakapi server, users do NOT
|
||||
// register or paste keys: the logged-in Overleaf user's identity is asserted
|
||||
// to Wakapi via a trusted HTTP header (WAKAPI_TRUSTED_HEADER_AUTH on the
|
||||
// Wakapi side), and Wakapi auto-creates the matching user on first use.
|
||||
// A per-user stored key (manual wakatime.com flow) always takes priority,
|
||||
// so users who prefer their own wakatime.com account can still link one.
|
||||
// Configure via services/web/config/settings.defaults.js (wakatime block).
|
||||
|
||||
const W = Settings.wakatime || {}
|
||||
|
||||
function sharedIdentityActive() {
|
||||
return W.sharedIdentity === true
|
||||
}
|
||||
|
||||
function sharedAuthHeaders(email) {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
// Same UA rules as buildHeaders (wakapi user-agent parser).
|
||||
'User-Agent': 'wakatime/1.0.0 (linux) overleaf/1.0 overleaf-wakatime/1.0',
|
||||
[W.sharedIdentityHeader || 'Remote-User']: email,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve how to authenticate on behalf of a user, WITHOUT storing anything:
|
||||
* 1. stored credentials (manual wakatime.com / custom key) — always wins
|
||||
* 2. shared deployment identity (logged-in user's email via trusted header)
|
||||
* 3. nothing (caller decides the error)
|
||||
*/
|
||||
async function resolveAuth(userId, email) {
|
||||
const credentials = await TokenManager.getCredentials(userId)
|
||||
if (credentials) {
|
||||
return {
|
||||
kind: 'key',
|
||||
url: normalizeApiUrl(credentials.apiUrl),
|
||||
headers: buildHeaders(credentials.apiKey),
|
||||
}
|
||||
}
|
||||
if (sharedIdentityActive() && email) {
|
||||
return {
|
||||
kind: 'shared',
|
||||
url: normalizeApiUrl(W.sharedIdentityApiUrl),
|
||||
headers: sharedAuthHeaders(email),
|
||||
}
|
||||
}
|
||||
return { kind: 'none', url: null, headers: null }
|
||||
}
|
||||
|
||||
function normalizeApiUrl(apiUrl) {
|
||||
return (apiUrl || DEFAULT_API_URL).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function buildHeaders(apiKey, opts = {}) {
|
||||
return {
|
||||
Authorization: `Basic ${Buffer.from(apiKey).toString('base64')}`,
|
||||
'Content-Type': 'application/json',
|
||||
// Must start with "wakatime/" and have a recognized OS token in
|
||||
// parens next, or WakaTime/Wakapi's user-agent parser bails out and
|
||||
// reports the editor as "unknown" — see wakapi/utils/http.go
|
||||
// ParseUserAgent, which requires this exact shape.
|
||||
'User-Agent': 'wakatime/1.0.0 (linux) overleaf/1.0 overleaf-wakatime/1.0',
|
||||
...opts,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a (apiUrl, apiKey) pair works, without persisting anything.
|
||||
* Uses GET /users/current, which every WakaTime-compatible server
|
||||
* (wakatime.com and Wakapi) implements for auth verification.
|
||||
*/
|
||||
async function verifyCredentials(apiUrl, apiKey) {
|
||||
const url = normalizeApiUrl(apiUrl)
|
||||
try {
|
||||
await fetchJson(`${url}/users/current`, {
|
||||
headers: buildHeaders(apiKey),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
normalizeApiError(err, 'verifyCredentials')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the user has linked (and can still authenticate with)
|
||||
* a WakaTime-compatible account — or is covered by the deployment-managed
|
||||
* shared identity (this box's own Wakapi server, trusted-header auth).
|
||||
*/
|
||||
async function getConnectionStatus(userId, email) {
|
||||
const auth = await resolveAuth(userId, email)
|
||||
if (auth.kind === 'none') return { connected: false }
|
||||
|
||||
try {
|
||||
await fetchJson(`${auth.url}/users/current`, {
|
||||
headers: auth.headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
return {
|
||||
connected: true,
|
||||
apiUrl: auth.url,
|
||||
shared: auth.kind === 'shared' || undefined,
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ userId, kind: auth.kind, err }, 'WakaTime connection check failed')
|
||||
return { connected: false, apiUrl: auth.url, error: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single heartbeat on behalf of a user.
|
||||
* https://wakatime.com/developers#heartbeats
|
||||
*/
|
||||
async function sendHeartbeat(userId, email, heartbeat) {
|
||||
const auth = await resolveAuth(userId, email)
|
||||
if (auth.kind === 'none') {
|
||||
throw new ServiceNotConfiguredError({
|
||||
message: sharedIdentityActive()
|
||||
? 'WakaTime not configured for this user'
|
||||
: 'WakaTime credentials missing',
|
||||
info: { userId, status: 400 },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchNothing(`${auth.url}/users/current/heartbeats`, {
|
||||
method: 'POST',
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify(heartbeat),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
} catch (err) {
|
||||
normalizeApiError(err, 'sendHeartbeat')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a batch of heartbeats on behalf of a user.
|
||||
* https://wakatime.com/developers#heartbeats (bulk)
|
||||
*/
|
||||
async function sendHeartbeatsBulk(userId, email, heartbeats) {
|
||||
const auth = await resolveAuth(userId, email)
|
||||
if (auth.kind === 'none') {
|
||||
throw new ServiceNotConfiguredError({
|
||||
message: sharedIdentityActive()
|
||||
? 'WakaTime not configured for this user'
|
||||
: 'WakaTime credentials missing',
|
||||
info: { userId, status: 400 },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// WakaTime's /heartbeats.bulk endpoint accepts ONE heartbeat object per
|
||||
// request (same contract on wakapi: the .bulk route uses the single
|
||||
// heartbeat handler — batch semantics are throughput, not array body).
|
||||
// So a "bulk" send is a loop over the .bulk endpoint: sequential, at
|
||||
// most MAX_BULK_HEARTBEATS (50) per call, each 2xx-counting.
|
||||
for (const heartbeat of heartbeats) {
|
||||
await fetchNothing(`${auth.url}/users/current/heartbeats.bulk`, {
|
||||
method: 'POST',
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify(heartbeat),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
normalizeApiError(err, 'sendHeartbeatsBulk')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total tracked time for a single project, over the last `range` days
|
||||
* (default 7), by requesting a summaries range and summing the per-project
|
||||
* breakdown. Works against both wakatime.com and Wakapi.
|
||||
*/
|
||||
async function getProjectSummary(userId, email, projectName, range = 7) {
|
||||
const auth = await resolveAuth(userId, email)
|
||||
if (auth.kind === 'none') return null
|
||||
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - range * 24 * 60 * 60 * 1000)
|
||||
const qs = new URLSearchParams({
|
||||
start: start.toISOString().slice(0, 10),
|
||||
end: end.toISOString().slice(0, 10),
|
||||
project: projectName,
|
||||
})
|
||||
|
||||
try {
|
||||
const summary = await fetchJson(
|
||||
`${auth.url}/users/current/summaries?${qs.toString()}`,
|
||||
{
|
||||
headers: auth.headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}
|
||||
)
|
||||
|
||||
const totalSeconds = (summary.data || []).reduce((sum, day) => {
|
||||
const projectEntry = (day.projects || []).find(
|
||||
p => p.name === projectName
|
||||
)
|
||||
return sum + (projectEntry?.total_seconds || 0)
|
||||
}, 0)
|
||||
|
||||
return { totalSeconds, rangeDays: range }
|
||||
} catch (err) {
|
||||
normalizeApiError(err, 'getProjectSummary')
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkAccount(userId) {
|
||||
await TokenManager.removeCredentials(userId)
|
||||
}
|
||||
|
||||
function normalizeApiError(err, operation) {
|
||||
logger.error({ operation }, 'WakaTime API request failed')
|
||||
|
||||
if (err.name === 'AbortError') {
|
||||
throw new OError('WakaTime request timed out', {
|
||||
operation,
|
||||
status: 504,
|
||||
}).withCause(err)
|
||||
}
|
||||
|
||||
if (!(err instanceof RequestFailedError)) {
|
||||
throw new OError('Something wrong with WakaTime request', {
|
||||
operation,
|
||||
status: 500,
|
||||
}).withCause(err)
|
||||
}
|
||||
|
||||
const status = err.response?.status || 500
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
throw new ForbiddenError({
|
||||
message: 'Access denied',
|
||||
info: { operation, status },
|
||||
}).withCause(err)
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
throw new NotFoundError({
|
||||
message: 'Not found',
|
||||
info: { operation, status },
|
||||
}).withCause(err)
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
throw new TooManyRequestsError({
|
||||
message: 'Rate limit exceeded',
|
||||
info: { operation, status },
|
||||
}).withCause(err)
|
||||
}
|
||||
|
||||
throw new OError('WakaTime request error', { operation, status }).withCause(
|
||||
err
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
verifyCredentials,
|
||||
getConnectionStatus,
|
||||
sendHeartbeat,
|
||||
sendHeartbeatsBulk,
|
||||
getProjectSummary,
|
||||
unlinkAccount,
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
|
||||
import { Project } from '../../../../app/src/models/Project.mjs'
|
||||
import WakaTimeApiClient from './WakaTimeApiClient.mjs'
|
||||
import TokenManager from './TokenManager.mjs'
|
||||
|
||||
const MAX_BULK_HEARTBEATS = 50
|
||||
|
||||
// Deployment-managed identity: assert the LOGGED-IN user's email to the
|
||||
// bundled Wakapi via trusted-header auth (never a client-supplied value).
|
||||
function sessionEmail(req) {
|
||||
const user = SessionManager.getSessionUser(req.session)
|
||||
return user?.email || null
|
||||
}
|
||||
|
||||
function sendError(res, err, operation) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const status = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, operation }, 'WakaTime request failed')
|
||||
res.status(status).json({ message: err.message })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /user/wakatime/status
|
||||
*/
|
||||
async function getConnectionStatus(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const email = sessionEmail(req)
|
||||
try {
|
||||
const status = await WakaTimeApiClient.getConnectionStatus(userId, email)
|
||||
res.json(status)
|
||||
} catch (err) {
|
||||
sendError(res, err, 'getConnectionStatus')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /user/wakatime
|
||||
* body: { apiUrl, apiKey }
|
||||
* Verifies the credentials against the target server before storing them.
|
||||
*/
|
||||
async function link(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { apiUrl, apiKey } = req.body || {}
|
||||
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
return res.status(400).json({ message: 'apiKey is required' })
|
||||
}
|
||||
|
||||
try {
|
||||
await WakaTimeApiClient.verifyCredentials(apiUrl, apiKey)
|
||||
await TokenManager.storeCredentials(userId, apiUrl, apiKey)
|
||||
res.sendStatus(200)
|
||||
} catch (err) {
|
||||
sendError(res, err, 'link')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /user/wakatime
|
||||
*/
|
||||
async function unlink(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
try {
|
||||
await WakaTimeApiClient.unlinkAccount(userId)
|
||||
res.sendStatus(200)
|
||||
} catch (err) {
|
||||
sendError(res, err, 'unlink')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /project/:project_id/wakatime/heartbeat
|
||||
* body: a single WakaTime heartbeat payload (entity, type, time, ...)
|
||||
* Forwards the heartbeat to the user's configured WakaTime/Wakapi server,
|
||||
* filling in the project name server-side so the client never needs to
|
||||
* know (or spoof) it.
|
||||
*/
|
||||
async function heartbeat(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const email = sessionEmail(req)
|
||||
const { project_id: projectId } = req.params
|
||||
|
||||
try {
|
||||
const project = await Project.findById(projectId, 'name').exec()
|
||||
if (!project) return res.sendStatus(404)
|
||||
|
||||
await WakaTimeApiClient.sendHeartbeat(userId, email, {
|
||||
...req.body,
|
||||
project: project.name,
|
||||
})
|
||||
res.sendStatus(202)
|
||||
} catch (err) {
|
||||
sendError(res, err, 'heartbeat')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /project/:project_id/wakatime/heartbeats/bulk
|
||||
* body: an array of WakaTime heartbeat payloads
|
||||
*/
|
||||
async function heartbeatsBulk(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const email = sessionEmail(req)
|
||||
const { project_id: projectId } = req.params
|
||||
const heartbeats = Array.isArray(req.body) ? req.body : []
|
||||
|
||||
if (heartbeats.length === 0) return res.sendStatus(204)
|
||||
if (heartbeats.length > MAX_BULK_HEARTBEATS) {
|
||||
return res.status(400).json({ message: 'too many heartbeats in one batch' })
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await Project.findById(projectId, 'name').exec()
|
||||
if (!project) return res.sendStatus(404)
|
||||
|
||||
await WakaTimeApiClient.sendHeartbeatsBulk(
|
||||
userId,
|
||||
email,
|
||||
heartbeats.map(h => ({ ...h, project: project.name }))
|
||||
)
|
||||
res.sendStatus(202)
|
||||
} catch (err) {
|
||||
sendError(res, err, 'heartbeatsBulk')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /project/:project_id/wakatime/summary
|
||||
* Returns total tracked time (in seconds) for this project over the last
|
||||
* `range` days (query param, default 7).
|
||||
*/
|
||||
async function summary(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const email = sessionEmail(req)
|
||||
const { project_id: projectId } = req.params
|
||||
const range = Math.min(parseInt(req.query.range, 10) || 7, 90)
|
||||
|
||||
try {
|
||||
const project = await Project.findById(projectId, 'name').exec()
|
||||
if (!project) return res.sendStatus(404)
|
||||
|
||||
const result = await WakaTimeApiClient.getProjectSummary(
|
||||
userId,
|
||||
email,
|
||||
project.name,
|
||||
range
|
||||
)
|
||||
if (!result) return res.json({ connected: false })
|
||||
|
||||
res.json({ connected: true, ...result })
|
||||
} catch (err) {
|
||||
sendError(res, err, 'summary')
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getConnectionStatus,
|
||||
link,
|
||||
unlink,
|
||||
heartbeat,
|
||||
heartbeatsBulk,
|
||||
summary,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import WakaTimeController from './WakaTimeController.mjs'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
|
||||
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.debug({}, 'Init wakatime router')
|
||||
|
||||
// account-level linking (Account Settings)
|
||||
webRouter.get(
|
||||
'/user/wakatime/status',
|
||||
AuthenticationController.requireLogin(),
|
||||
WakaTimeController.getConnectionStatus
|
||||
)
|
||||
|
||||
webRouter.put(
|
||||
'/user/wakatime',
|
||||
AuthenticationController.requireLogin(),
|
||||
WakaTimeController.link
|
||||
)
|
||||
|
||||
webRouter.delete(
|
||||
'/user/wakatime',
|
||||
AuthenticationController.requireLogin(),
|
||||
WakaTimeController.unlink
|
||||
)
|
||||
|
||||
// per-project heartbeats, sent by the editor's activity tracker
|
||||
webRouter.post(
|
||||
'/project/:project_id/wakatime/heartbeat',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
WakaTimeController.heartbeat
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/project/:project_id/wakatime/heartbeats/bulk',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
WakaTimeController.heartbeatsBulk
|
||||
)
|
||||
|
||||
// total time tracked for a project, shown in the Integrations panel
|
||||
webRouter.get(
|
||||
'/project/:project_id/wakatime/summary',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
WakaTimeController.summary
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { putJSON } from '@/infrastructure/fetch-json'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import Notification from '@/shared/components/notification'
|
||||
|
||||
const DEFAULT_API_URL = 'https://wakatime.com/api/v1'
|
||||
|
||||
export default function WakaTimeConnectModal({
|
||||
show,
|
||||
handleHide,
|
||||
onLinked,
|
||||
}: {
|
||||
show: boolean
|
||||
handleHide: () => void
|
||||
onLinked: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [apiUrl, setApiUrl] = useState(DEFAULT_API_URL)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await putJSON('/user/wakatime', {
|
||||
body: { apiUrl: apiUrl.trim() || DEFAULT_API_URL, apiKey: apiKey.trim() },
|
||||
})
|
||||
setApiKey('')
|
||||
onLinked()
|
||||
handleHide()
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.data?.message ||
|
||||
err?.message ||
|
||||
t('generic_something_went_wrong')
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OLModal show={show} onHide={handleHide} backdrop="static">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('connect_wakatime', { defaultValue: 'Connect WakaTime' })}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<p className="small">
|
||||
{t('wakatime_connect_description', {
|
||||
defaultValue:
|
||||
'Works with wakatime.com or a self-hosted Wakapi server. Create an API key on your account’s settings page and paste it below.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<OLFormGroup controlId="wakatime-api-url">
|
||||
<OLFormLabel>{t('api_url', { defaultValue: 'API URL' })}</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="text"
|
||||
value={apiUrl}
|
||||
placeholder={DEFAULT_API_URL}
|
||||
onChange={e => setApiUrl(e.target.value)}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup controlId="wakatime-api-key">
|
||||
<OLFormLabel>{t('api_key', { defaultValue: 'API Key' })}</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="password"
|
||||
value={apiKey}
|
||||
required
|
||||
autoComplete="off"
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
{error && <Notification type="error" content={error} />}
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={handleHide} disabled={isSaving}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={isSaving || !apiKey.trim()}
|
||||
>
|
||||
{isSaving ? t('saving') : t('connect', { defaultValue: 'Connect' })}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</form>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import WakaTimeLogo from '@/shared/svgs/wakatime-logo'
|
||||
import IntegrationCard from '@/features/integrations-panel/integration-card'
|
||||
import WakaTimeConnectModal from './wakatime-connect-modal'
|
||||
|
||||
type SummaryResponse =
|
||||
| { connected: false }
|
||||
| { connected: true; totalSeconds: number; rangeDays: number }
|
||||
|
||||
function formatDuration(totalSeconds: number) {
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
if (hours === 0 && minutes === 0) return '0m'
|
||||
if (hours === 0) return `${minutes}m`
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
|
||||
function WakaTimeCard() {
|
||||
const { t } = useTranslation()
|
||||
const { project } = useProjectContext()
|
||||
const projectId = project?._id
|
||||
|
||||
const [summary, setSummary] = useState<SummaryResponse | null>(null)
|
||||
const [showConnectModal, setShowConnectModal] = useState(false)
|
||||
|
||||
const loadSummary = () => {
|
||||
if (!projectId) return
|
||||
getJSON<SummaryResponse>(`/project/${projectId}/wakatime/summary`)
|
||||
.then(setSummary)
|
||||
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||
}
|
||||
|
||||
useEffect(loadSummary, [projectId])
|
||||
|
||||
if (!projectId) return null
|
||||
|
||||
const isConnected = summary?.connected === true
|
||||
|
||||
const description = summary?.connected
|
||||
? t('wakatime_time_this_week', {
|
||||
defaultValue: '__duration__ tracked (last __days__ days)',
|
||||
duration: formatDuration(summary.totalSeconds),
|
||||
days: summary.rangeDays,
|
||||
})
|
||||
: t('wakatime_connect_to_track_time', {
|
||||
defaultValue: 'Connect WakaTime to track time on this project',
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegrationCard
|
||||
title="WakaTime"
|
||||
description={description}
|
||||
icon={<WakaTimeLogo size={32} />}
|
||||
showPaywallBadge={false}
|
||||
onClick={
|
||||
isConnected
|
||||
? undefined
|
||||
: () => setShowConnectModal(true)
|
||||
}
|
||||
href={
|
||||
isConnected
|
||||
? '/user/settings#project-sync'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<WakaTimeConnectModal
|
||||
show={showConnectModal}
|
||||
handleHide={() => setShowConnectModal(false)}
|
||||
onLinked={loadSummary}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WakaTimeCard
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getJSON, deleteJSON } from '@/infrastructure/fetch-json'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import WakaTimeLogo from '@/shared/svgs/wakatime-logo'
|
||||
import WakaTimeConnectModal from './wakatime-connect-modal'
|
||||
|
||||
type WakaTimeStatus = { connected: boolean; apiUrl?: string; error?: boolean }
|
||||
|
||||
/**
|
||||
* WakaTime account linking widget for the Account Settings page.
|
||||
* Unlike Zotero/GitHub, there's no OAuth handshake: the user pastes an
|
||||
* API key (from wakatime.com or a self-hosted Wakapi instance) directly.
|
||||
*
|
||||
* Registered via overleafModuleImports.integrationLinkingWidgets.
|
||||
*/
|
||||
export const WakaTimeWidget = function WakaTimeWidget() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
isLoading: isCheckingConn,
|
||||
isError: isErrorConnCheck,
|
||||
runAsync: runAsyncConnCheck,
|
||||
data: status,
|
||||
setData: setStatus,
|
||||
} = useAsync<WakaTimeStatus>()
|
||||
|
||||
const {
|
||||
isLoading: isUnlinking,
|
||||
isError: isErrorUnlink,
|
||||
runAsync: runAsyncUnlink,
|
||||
} = useAsync<void>()
|
||||
|
||||
const [showConnectModal, setShowConnectModal] = useState(false)
|
||||
|
||||
const handleConnCheck = useCallback(() => {
|
||||
runAsyncConnCheck(getJSON('/user/wakatime/status')).catch(err =>
|
||||
debugConsole.error(err?.data?.message || err?.message || err)
|
||||
)
|
||||
}, [runAsyncConnCheck])
|
||||
|
||||
useEffect(() => {
|
||||
handleConnCheck()
|
||||
}, [handleConnCheck])
|
||||
|
||||
const handleUnlink = useCallback(() => {
|
||||
runAsyncUnlink(deleteJSON('/user/wakatime'))
|
||||
.then(() => setStatus({ connected: false }))
|
||||
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||
}, [runAsyncUnlink, setStatus])
|
||||
|
||||
if (isCheckingConn) {
|
||||
return (
|
||||
<div className="settings-widget-container">
|
||||
<div>
|
||||
<WakaTimeLogo size={40} />
|
||||
</div>
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4>WakaTime</h4>
|
||||
</div>
|
||||
<p className="small">
|
||||
<span>{t('loading')}…</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isConnected = !!status?.connected
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-widget-container">
|
||||
<div>
|
||||
<WakaTimeLogo size={40} />
|
||||
</div>
|
||||
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4 id="wakatime-link">WakaTime</h4>
|
||||
</div>
|
||||
|
||||
<p className="small">
|
||||
{t('wakatime_widget_description', {
|
||||
defaultValue:
|
||||
'Track coding time in your projects with WakaTime or a self-hosted Wakapi server.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
{isConnected && status?.apiUrl && (
|
||||
<p className="small text-muted">{status.apiUrl}</p>
|
||||
)}
|
||||
|
||||
{isErrorConnCheck && (
|
||||
<Notification
|
||||
type="error"
|
||||
content={t('problem_checking_connection_with_provider', {
|
||||
provider: 'WakaTime',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isErrorUnlink && (
|
||||
<Notification type="error" content={t('generic_something_went_wrong')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isConnected ? (
|
||||
<OLButton
|
||||
variant="danger-ghost"
|
||||
onClick={handleUnlink}
|
||||
disabled={isUnlinking}
|
||||
>
|
||||
{isUnlinking ? t('unlinking') : t('unlink')}
|
||||
</OLButton>
|
||||
) : (
|
||||
<OLButton variant="secondary" onClick={() => setShowConnectModal(true)}>
|
||||
{t('link')}
|
||||
</OLButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WakaTimeConnectModal
|
||||
show={showConnectModal}
|
||||
handleHide={() => setShowConnectModal(false)}
|
||||
onLinked={handleConnCheck}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ViewPlugin } from '@codemirror/view'
|
||||
import type { DocumentContainer } from '@/features/ide-react/editor/document-container'
|
||||
import type { EditorFacade, ChangeDescription } from '@/features/source-editor/extensions/realtime'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
notifyFileOpened,
|
||||
notifyLocalEdit,
|
||||
notifyFileSaved,
|
||||
wakaTimeLog,
|
||||
} from './wakatime-tracker'
|
||||
|
||||
/**
|
||||
* Registered via overleafModuleImports.sourceEditorExtensions.
|
||||
*
|
||||
* Sends WakaTime heartbeats for genuinely local editor activity, without
|
||||
* touching the CodeMirror/ShareJS realtime plumbing directly. It relies on
|
||||
* the `origin` already computed by the realtime extension's `chooseOrigin`:
|
||||
* `origin === undefined` on a docChanged transaction means a real local
|
||||
* keystroke, as opposed to `'remote'` (a collaborator's edit applied via
|
||||
* OT), `'undo'` or `'reject'`.
|
||||
*/
|
||||
export const extension = (options: Record<string, any>) => {
|
||||
if (!getMeta('ol-ExposedSettings')?.wakaTimeEnabled) {
|
||||
wakaTimeLog('extension disabled: ol-ExposedSettings.wakaTimeEnabled is falsy')
|
||||
return []
|
||||
}
|
||||
|
||||
const currentDoc = options.currentDoc?.currentDoc as
|
||||
| DocumentContainer
|
||||
| undefined
|
||||
const docName = options.docName as string
|
||||
const projectId = getMeta('ol-project_id') as string
|
||||
|
||||
if (!currentDoc || !docName || !projectId) {
|
||||
wakaTimeLog('extension bailing out, missing data:', {
|
||||
hasCurrentDoc: !!currentDoc,
|
||||
docName,
|
||||
projectId,
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
wakaTimeLog('extension attached for', docName, 'in project', projectId)
|
||||
notifyFileOpened(projectId, docName)
|
||||
|
||||
const handleChange = (_editor: EditorFacade, change: ChangeDescription) => {
|
||||
if (change.origin === undefined) {
|
||||
notifyLocalEdit(projectId, docName)
|
||||
}
|
||||
}
|
||||
|
||||
let recentlyEditedLocally = false
|
||||
const markLocalEdit = (_editor: EditorFacade, change: ChangeDescription) => {
|
||||
if (change.origin === undefined) {
|
||||
recentlyEditedLocally = true
|
||||
}
|
||||
}
|
||||
|
||||
// document-container.ts dispatches this window event (not a `trigger` on
|
||||
// `currentDoc` itself) whenever the ShareJS doc for *any* open file has
|
||||
// drained its pending ops — filter to our own doc_id.
|
||||
const handleSaved = (event: Event) => {
|
||||
const { id } = (event as CustomEvent<{ id: string }>).detail || {}
|
||||
if (id === currentDoc.doc_id && recentlyEditedLocally) {
|
||||
notifyFileSaved(projectId, docName)
|
||||
recentlyEditedLocally = false
|
||||
}
|
||||
}
|
||||
|
||||
const attach = () => {
|
||||
wakaTimeLog('cm6 change listeners attached for', docName)
|
||||
currentDoc.cm6?.on('change', handleChange)
|
||||
currentDoc.cm6?.on('change', markLocalEdit)
|
||||
}
|
||||
|
||||
if (currentDoc.cm6) {
|
||||
attach()
|
||||
} else {
|
||||
wakaTimeLog('cm6 not ready yet, waiting for cm6:attach for', docName)
|
||||
currentDoc.on('cm6:attach', attach)
|
||||
}
|
||||
|
||||
window.addEventListener('doc:saved', handleSaved)
|
||||
|
||||
return ViewPlugin.define(() => ({
|
||||
destroy() {
|
||||
currentDoc.off('cm6:attach', attach)
|
||||
window.removeEventListener('doc:saved', handleSaved)
|
||||
currentDoc.cm6?.off('change', handleChange)
|
||||
currentDoc.cm6?.off('change', markLocalEdit)
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
const wakaTimeDebugLogging = !!getMeta('ol-ExposedSettings')?.wakaTimeDebugLogging
|
||||
|
||||
export function wakaTimeLog(...args: unknown[]) {
|
||||
if (wakaTimeDebugLogging) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[wakatime]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
// WakaTime clients only send a heartbeat if more than this long has passed
|
||||
// since the last one for the same file, or the file was switched/saved.
|
||||
const HEARTBEAT_THROTTLE_MS = 2 * 60 * 1000
|
||||
|
||||
// WakaTime/Wakapi identify the sending editor by parsing this string as
|
||||
// `wakatime/version (os) editorName/editorVersion editorName-wakatime/pluginVersion`
|
||||
// (the same convention every official wakatime-cli-based plugin uses).
|
||||
// Must start with "wakatime/" and have a recognized OS token in parens
|
||||
// next, or the parser bails out and reports the editor as "unknown".
|
||||
const USER_AGENT = 'wakatime/1.0.0 (linux) overleaf/1.0 overleaf-wakatime/1.0'
|
||||
|
||||
const EXTENSION_TO_LANGUAGE: Record<string, string> = {
|
||||
tex: 'LaTeX',
|
||||
ltx: 'LaTeX',
|
||||
sty: 'TeX',
|
||||
cls: 'TeX',
|
||||
bib: 'BibTeX',
|
||||
bbx: 'TeX',
|
||||
cbx: 'TeX',
|
||||
md: 'Markdown',
|
||||
rnw: 'R',
|
||||
py: 'Python',
|
||||
r: 'R',
|
||||
json: 'JSON',
|
||||
yml: 'YAML',
|
||||
yaml: 'YAML',
|
||||
}
|
||||
|
||||
function languageForFile(fileName: string): string | undefined {
|
||||
const match = /\.([^./]+)$/.exec(fileName)
|
||||
const ext = match?.[1]?.toLowerCase()
|
||||
return ext ? EXTENSION_TO_LANGUAGE[ext] : undefined
|
||||
}
|
||||
|
||||
const lastHeartbeatAt = new Map<string, number>()
|
||||
|
||||
function buildHeartbeat(docName: string, isWrite: boolean) {
|
||||
const heartbeat: Record<string, unknown> = {
|
||||
entity: docName,
|
||||
type: 'file',
|
||||
time: Date.now() / 1000,
|
||||
is_write: isWrite,
|
||||
user_agent: USER_AGENT,
|
||||
branch: 'main',
|
||||
}
|
||||
const language = languageForFile(docName)
|
||||
if (language) heartbeat.language = language
|
||||
return heartbeat
|
||||
}
|
||||
|
||||
function send(projectId: string, docName: string, isWrite: boolean) {
|
||||
const heartbeat = buildHeartbeat(docName, isWrite)
|
||||
wakaTimeLog('sending heartbeat', { projectId, docName, isWrite, heartbeat })
|
||||
postJSON(`/project/${projectId}/wakatime/heartbeat`, {
|
||||
body: heartbeat,
|
||||
}).then(
|
||||
res => wakaTimeLog('heartbeat sent OK', { docName, res }),
|
||||
err => {
|
||||
// WakaTime not linked, unreachable, etc — never surface this to the user
|
||||
wakaTimeLog('heartbeat FAILED', {
|
||||
docName,
|
||||
status: err?.response?.status,
|
||||
message: err?.message || err,
|
||||
data: err?.data,
|
||||
})
|
||||
debugConsole.log('wakatime heartbeat not sent', err?.message || err)
|
||||
}
|
||||
)
|
||||
lastHeartbeatAt.set(docName, Date.now())
|
||||
}
|
||||
|
||||
/** Called whenever the user opens/switches to a file in the editor. */
|
||||
export function notifyFileOpened(projectId: string, docName: string) {
|
||||
wakaTimeLog('notifyFileOpened', docName)
|
||||
send(projectId, docName, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on every local (non-collaborator, non-undo/reject) CodeMirror edit.
|
||||
* Throttled to at most one heartbeat per file per HEARTBEAT_THROTTLE_MS.
|
||||
*/
|
||||
export function notifyLocalEdit(projectId: string, docName: string) {
|
||||
const last = lastHeartbeatAt.get(docName) || 0
|
||||
if (Date.now() - last < HEARTBEAT_THROTTLE_MS) {
|
||||
wakaTimeLog('notifyLocalEdit throttled', docName)
|
||||
return
|
||||
}
|
||||
wakaTimeLog('notifyLocalEdit', docName)
|
||||
send(projectId, docName, false)
|
||||
}
|
||||
|
||||
/** Called when the document has just been saved (acked by ShareJS). */
|
||||
export function notifyFileSaved(projectId: string, docName: string) {
|
||||
wakaTimeLog('notifyFileSaved', docName)
|
||||
send(projectId, docName, true)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Settings from '@overleaf/settings'
|
||||
import WakaTimeRouter from './app/src/WakaTimeRouter.mjs'
|
||||
|
||||
Settings.wakatime = {
|
||||
enabled: process.env.WAKATIME_INTEGRATION_ENABLED !== 'false',
|
||||
debugLogging: process.env.WAKATIME_DEBUG_LOGGING === 'true',
|
||||
}
|
||||
|
||||
const WakaTimeModule = Settings.wakatime.enabled
|
||||
? { router: WakaTimeRouter }
|
||||
: {}
|
||||
|
||||
export default WakaTimeModule
|
||||
@@ -68,6 +68,7 @@ async function refreshLinkedFile(
|
||||
parentFolderId,
|
||||
userId
|
||||
) {
|
||||
linkedFileData = _normalizeLegacyData(linkedFileData)
|
||||
logger.debug(
|
||||
{ projectId, userId, linkedFileData },
|
||||
'refreshing Zotero linked file'
|
||||
@@ -108,7 +109,7 @@ async function _getBibtex(linkedFileData) {
|
||||
return await ZoteroApiClient.getLibraryBibtex(
|
||||
userId,
|
||||
linkedFileData.group_id, // == null for main library
|
||||
linkedFileData.format || 'bibtex'
|
||||
linkedFileData.format
|
||||
)
|
||||
} catch (err) {
|
||||
|
||||
@@ -129,6 +130,24 @@ async function _getBibtex(linkedFileData) {
|
||||
}
|
||||
}
|
||||
|
||||
// Files imported before "Zotero: fix for 6.3.0" use the old key names.
|
||||
function _normalizeLegacyData(data) {
|
||||
const {
|
||||
zoteroGroupId,
|
||||
importedByUserId,
|
||||
importedByName,
|
||||
bibFormat,
|
||||
...rest
|
||||
} = data
|
||||
return {
|
||||
...rest,
|
||||
group_id: rest.group_id ?? zoteroGroupId ?? null,
|
||||
importer_id: rest.importer_id ?? importedByUserId,
|
||||
importer_name: rest.importer_name ?? importedByName,
|
||||
format: rest.format ?? bibFormat,
|
||||
}
|
||||
}
|
||||
|
||||
function _sanitizeData(data) {
|
||||
return {
|
||||
provider: 'zotero',
|
||||
|
||||
@@ -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] <filename>
|
||||
*/
|
||||
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)
|
||||
@@ -56,5 +56,7 @@ export type ExposedSettings = {
|
||||
linkedInInsightsPartnerId?: string
|
||||
githubSyncEnabled: boolean
|
||||
zoteroEnabled: boolean
|
||||
wakaTimeEnabled: boolean
|
||||
languageToolAvailable: boolean
|
||||
enablePandocConversions: boolean
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
export OLD_VERSION="sharelatex/sharelatex:6.3.0"
|
||||
export NEW_VERSION="sharelatex/sharelatex:6.3.1"
|
||||
@@ -0,0 +1 @@
|
||||
.env
|
||||
@@ -0,0 +1,155 @@
|
||||
# LanguageTool server (Docker Compose)
|
||||
|
||||
Standalone [LanguageTool](https://languagetool.org) grammar-check server used by
|
||||
the Overleaf integration in `services/web/modules/languagetool`
|
||||
(Overleaf CE fork repo, see the module's `README.md`).
|
||||
|
||||
- **Image:** `erikvl87/languagetool:latest` (community server fork of LanguageTool;
|
||||
supports `level=default` and `level=picky` — other levels return HTTP 400)
|
||||
- **Container name:** `languagetool` (overleaf reaches it by this hostname on the shared network)
|
||||
- **Host port:** `127.0.0.1:32768` → container `8010` (pinned, stable across restarts)
|
||||
- **Compose project name:** `languagetools_server_compose` (directory name)
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
tools/languagetools_server_compose/
|
||||
├── compose.yaml # top-level: `extends` the service in languagetool/compose.yaml
|
||||
├── up.sh # docker compose up -d
|
||||
├── down.sh # docker compose down
|
||||
├── up_languagetool.sh # docker compose up -d languagetool
|
||||
├── down_languagetool.sh # docker compose down languagetool
|
||||
├── logs_languagetool.sh # docker compose logs -f languagetool
|
||||
├── autoupdate_languagetool.sh # pull latest image, recreate container only if changed
|
||||
├── lt-test.tex # test document: sentences with verified expected detections
|
||||
└── languagetool/
|
||||
├── compose.yaml # actual service definition
|
||||
├── download_ngrams.sh # main n-gram set (de en es fr nl) → ngrams/
|
||||
├── download_ngrams_untested.sh # untested set (he it ru zh) → ngrams/untested/
|
||||
└── ngrams/ # n-gram data, mounted read-only into the container (gitignored)
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd tools/languagetools_server_compose
|
||||
|
||||
# 1. Populate n-gram data (one-time, ~45 GB disk; resumable, idempotent)
|
||||
./languagetool/download_ngrams.sh
|
||||
./languagetool/download_ngrams_untested.sh # optional: he/it/ru/zh
|
||||
|
||||
# 2. Start
|
||||
./up.sh
|
||||
|
||||
# 3. Verify (container must be healthy)
|
||||
docker ps --filter name=languagetool
|
||||
curl -s http://127.0.0.1:32768/v2/languages | head -c 200
|
||||
```
|
||||
|
||||
## N-gram data
|
||||
|
||||
LanguageTool loads per-language n-gram models from the directory given in
|
||||
`langtool_languageModel` (here: `/ngrams` ← `languagetool/ngrams/`).
|
||||
Every subdirectory named after a language code (e.g. `en/`, `de/`) is picked
|
||||
up — currently present: `de en es fr nl` (main) and `he it ru zh` (untested).
|
||||
|
||||
| Set | Languages | Size (extracted) | Zip sources |
|
||||
|---|---|---|---|
|
||||
| main | de en es fr nl | ~24 GB | `ngrams-<lang>-YYYYMMDD.zip` (~1.7–9 GB each) |
|
||||
| untested | he it ru zh | ~14 GB | `ngram-<lang>-YYYYMMDD.zip` (~0.2–2.3 GB each) |
|
||||
|
||||
- Downloads come from `https://languagetool.org/download/ngram-data/`
|
||||
(untested set: `.../ngram-data/untested/`).
|
||||
- Both scripts are idempotent: existing zips are skip-validated (`unzip -t`),
|
||||
downloads are resumable (`curl -C -`). Re-run safely after an interrupted transfer.
|
||||
- The downloaded zip archives stay in `ngrams/` (~21 GB total).
|
||||
Safe to `rm ngrams/*.zip` once extraction succeeded, to reclaim that space.
|
||||
- `an` (Aragonese) is **not** available on the server; it is kept commented
|
||||
out in `download_ngrams_untested.sh` in case it appears later.
|
||||
|
||||
> **Pitfall (cost us a debugging session):** if `ngrams/` does not exist on
|
||||
> the host, Docker creates an *empty* directory for the bind mount and the
|
||||
> server runs **without any n-gram models** — spelling detection
|
||||
> (`recieve`, `realy`, …) silently degrades while the API still answers.
|
||||
> Verify with `ls languagetool/ngrams/en` and a check request that
|
||||
> `MORFOLOGIK_RULE_EN_US` still fires.
|
||||
|
||||
## Service configuration (`languagetool/compose.yaml`)
|
||||
|
||||
| Setting | Value | Notes |
|
||||
|---|---|---|
|
||||
| `image` | `erikvl87/languagetool:latest` | community fork, HTTP REST API on 8010 |
|
||||
| `Java_Xms` / `Java_Xmx` | `512m` / `2g` | JVM heap |
|
||||
| `langtool_languageModel` | `/ngrams` | n-gram model directory (read-only bind mount) |
|
||||
| `ports` | `127.0.0.1:32768:8010` | host port pinned — do not reintroduce the ephemeral `127.0.0.1::8010` form |
|
||||
| `healthcheck` | `wget --spider http://localhost:8010/v2/languages` | 30s interval, 60s start period |
|
||||
| `restart` | `unless-stopped` | |
|
||||
|
||||
## Connecting Overleaf
|
||||
|
||||
The matching Overleaf side (CE fork v6.3.1) exposes the feature through
|
||||
`services/web/modules/languagetool/` — enabled via overleaf service env vars.
|
||||
In this deployment they live in `/data_1/docker/compose/overleaf/compose.yaml`:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
LANGUAGE_TOOL_ENABLED: "true"
|
||||
LANGUAGE_TOOL_HOST: languagetool # resolves on the shared docker network
|
||||
LANGUAGE_TOOL_PORT: "8010"
|
||||
```
|
||||
|
||||
- The overleaf container reaches LT through the shared network
|
||||
(`languagetools_server_compose_default`); from inside overleaf the host is
|
||||
`languagetool:8010`, from the host it is `127.0.0.1:32768`.
|
||||
- Overleaf checks `/v2/check` (POST, form-encoded `text`, `language`, `level`,
|
||||
plus `disabledRules`); client-side defaults: **picky level on**,
|
||||
spellcheck language = Overleaf default (`en-US`), and the checks are
|
||||
stored as **project settings** (shared with collaborators).
|
||||
- If `LANGUAGE_TOOL_ENABLED` is false or the proxy can't reach LT, Overleaf
|
||||
simply does not offer the feature (no client errors).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# basic
|
||||
curl -s -X POST http://127.0.0.1:32768/v2/check \
|
||||
-d 'language=en-US' -d 'text=You can recieve a bonus.' -d 'level=picky'
|
||||
|
||||
# picky vs default (passive voice is picky-only on this build)
|
||||
curl -s -X POST http://127.0.0.1:32768/v2/check \
|
||||
--data-urlencode 'language=en-US' --data-urlencode 'text=The error was detected by the test suite.' \
|
||||
-d 'level=default' # → no passive-voice match
|
||||
curl -s -X POST http://127.0.0.1:32768/v2/check \
|
||||
--data-urlencode 'language=en-US' --data-urlencode 'text=The error was detected by the test suite.' \
|
||||
-d 'level=picky' # → PASSIVE_VOICE_SIMPLE
|
||||
```
|
||||
|
||||
Editor test document: **`lt-test.tex`** in this directory.
|
||||
Verified expectations (2026-10-26, with the exact `disabledRules` the proxy sends):
|
||||
|
||||
| Picky mode | Underlines |
|
||||
|---|---|
|
||||
| **on** (Overleaf default) | **27** — section A (2 passive-voice) + section B (23) |
|
||||
| **off** | **25** — section A disappears, section B stays |
|
||||
|
||||
See the file header for the per-sentence rule annotations.
|
||||
|
||||
## Update / rollback
|
||||
|
||||
```bash
|
||||
./autoupdate_languagetool.sh # pull erikvl87/languagetool:latest, recreate only if image changed
|
||||
docker compose down && docker compose up -d # manual restart
|
||||
docker compose logs -f languagetool # tail logs
|
||||
```
|
||||
|
||||
Troubleshooting:
|
||||
|
||||
- **Overleaf can't reach LT** — from the overleaf container:
|
||||
`wget -O- http://languagetool:8010/v2/languages`. If DNS fails, the overleaf
|
||||
service is not attached to the `languagetools_server_compose_default` network
|
||||
(see the two `networks` blocks above; both must carry the same name).
|
||||
- **Port changed after a recreate** — always use the pinned form
|
||||
`"127.0.0.1:32768:8010"`; the old `"127.0.0.1::8010"` form picks a random
|
||||
host port per container creation.
|
||||
- **No spelling detections** — n-gram mount empty, see the pitfall above.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_NAME="languagetool"
|
||||
IMAGE_NAME="erikvl87/languagetool:latest"
|
||||
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
languagetool:
|
||||
extends:
|
||||
file: ./languagetool/compose.yaml
|
||||
service: languagetool
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
docker compose down languagetool
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ngrams
|
||||
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
languagetool:
|
||||
image: erikvl87/languagetool:latest
|
||||
container_name: languagetool
|
||||
hostname: languagetool
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
Java_Xms: 512m
|
||||
Java_Xmx: 2g
|
||||
langtool_languageModel: /ngrams
|
||||
ports:
|
||||
- "127.0.0.1:32768:8010"
|
||||
volumes:
|
||||
- ./ngrams:/ngrams:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --spider --timeout=5 http://localhost:8010/v2/languages 2>&1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download & extract LanguageTool n-gram data (main set: de en es fr nl)
|
||||
# into ngrams/ next to this script.
|
||||
#
|
||||
# Sources: https://languagetool.org/download/ngram-data/
|
||||
# ngrams-de-20150819.zip (~1.7 GB)
|
||||
# ngrams-en-20150817.zip (~9.0 GB)
|
||||
# ngrams-es-20150915.zip (~1.7 GB)
|
||||
# ngrams-fr-20150913.zip (~1.8 GB)
|
||||
# ngrams-nl-20181229.zip (~1.2 GB)
|
||||
#
|
||||
# Usage: ./download_ngrams.sh
|
||||
# Re-running is safe: already-downloaded & valid zips are skipped (download is
|
||||
# resumable via curl -C).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NGRAMS_DIR="$BASE/ngrams"
|
||||
BASE_URL="https://languagetool.org/download/ngram-data"
|
||||
|
||||
# lang -> zip filename (verified against server directory listing)
|
||||
declare -a FILES=(
|
||||
"ngrams-de-20150819.zip"
|
||||
"ngrams-en-20150817.zip"
|
||||
"ngrams-es-20150915.zip"
|
||||
"ngrams-fr-20150913.zip"
|
||||
"ngrams-nl-20181229.zip"
|
||||
)
|
||||
|
||||
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 1; }
|
||||
command -v unzip >/dev/null || { echo "ERROR: unzip not found" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$NGRAMS_DIR"
|
||||
echo "Target dir: $NGRAMS_DIR"
|
||||
|
||||
failed=0
|
||||
for zip in "${FILES[@]}"; do
|
||||
url="$BASE_URL/$zip"
|
||||
dest="$NGRAMS_DIR/$zip"
|
||||
|
||||
echo "=================================================================="
|
||||
echo "$zip"
|
||||
|
||||
if [ -f "$dest" ] && unzip -t "$dest" >/dev/null 2>&1; then
|
||||
echo " [skip] already downloaded and valid: $zip"
|
||||
else
|
||||
echo " [dl] $url"
|
||||
if ! curl --fail --location --retry 3 --retry-delay 5 -C - \
|
||||
--connect-timeout 30 --progress-bar -o "$dest" "$url"; then
|
||||
echo " [FAIL] download failed for $url (partial file kept for resume: $dest)" >&2
|
||||
rm -f "$dest" # 404/error body would corrupt the name for future runs
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " [extract] -> $NGRAMS_DIR"
|
||||
if ! unzip -o -q "$dest" -d "$NGRAMS_DIR"; then
|
||||
echo " [FAIL] extraction failed for $zip" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=================================================================="
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "Finished with ERRORS — re-run to resume failed downloads." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All done. Contents:"
|
||||
ls -lh "$NGRAMS_DIR"
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download & extract LanguageTool n-gram data (UNTESTED set) into ngrams/untested/
|
||||
# next to this script.
|
||||
#
|
||||
# Sources: https://languagetool.org/download/ngram-data/untested/
|
||||
# ngram-he-20150916.zip (~225 MB)
|
||||
# ngram-it-20150915.zip (~1.1 GB)
|
||||
# ngram-ru-20150914.zip (~2.3 GB)
|
||||
# ngram-zh-20150916.zip (~1.7 GB)
|
||||
#
|
||||
# NOTE: "an" (Aragonese) was requested but does NOT exist on the server
|
||||
# (checked ngram-data/, untested/, temp/, archive/ — only he/it/ru/zh are
|
||||
# offered). Kept commented out so it can be enabled if it appears later.
|
||||
#
|
||||
# Usage: ./download_ngrams_untested.sh
|
||||
# Re-running is safe: already-downloaded & valid zips are skipped (download is
|
||||
# resumable via curl -C).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NGRAMS_DIR="$BASE/ngrams"
|
||||
UNTESTED_DIR="$NGRAMS_DIR"
|
||||
BASE_URL="https://languagetool.org/download/ngram-data/untested"
|
||||
|
||||
# zip filenames (verified against server directory listing)
|
||||
declare -a FILES=(
|
||||
"ngram-he-20150916.zip"
|
||||
"ngram-it-20150915.zip"
|
||||
"ngram-ru-20150914.zip"
|
||||
"ngram-zh-20150916.zip"
|
||||
# "ngram-an-*.zip (404 — not available on the server as of 2025/2026)"
|
||||
)
|
||||
|
||||
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 1; }
|
||||
command -v unzip >/dev/null || { echo "ERROR: unzip not found" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$UNTESTED_DIR"
|
||||
echo "Target dir: $UNTESTED_DIR"
|
||||
|
||||
failed=0
|
||||
for zip in "${FILES[@]}"; do
|
||||
url="$BASE_URL/$zip"
|
||||
dest="$UNTESTED_DIR/$zip"
|
||||
|
||||
echo "=================================================================="
|
||||
echo "$zip"
|
||||
|
||||
if [ -f "$dest" ] && unzip -t "$dest" >/dev/null 2>&1; then
|
||||
echo " [skip] already downloaded and valid: $zip"
|
||||
else
|
||||
echo " [dl] $url"
|
||||
if ! curl --fail --location --retry 3 --retry-delay 5 -C - \
|
||||
--connect-timeout 30 --progress-bar -o "$dest" "$url"; then
|
||||
echo " [FAIL] download failed for $url (partial file kept for resume: $dest)" >&2
|
||||
rm -f "$dest" # 404/error body would corrupt the name for future runs
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " [extract] -> $UNTESTED_DIR"
|
||||
if ! unzip -o -q "$dest" -d "$UNTESTED_DIR"; then
|
||||
echo " [FAIL] extraction failed for $zip" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=================================================================="
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "Finished with ERRORS — re-run to resume failed downloads." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All done. Contents:"
|
||||
ls -lh "$UNTESTED_DIR"
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f languagetool
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
% LanguageTool level test — ext-ce-v6.3.1 + erikvl87/languagetool
|
||||
%
|
||||
% Section A: expected to be flagged ONLY in Picky mode (level=picky).
|
||||
% Section B: expected to be flagged in BOTH modes (default and picky).
|
||||
%
|
||||
% All sentences below were verified live against the running server
|
||||
% (2026-10-26), with the exact disabledRules the Overleaf proxy sends.
|
||||
%
|
||||
% Usage: open in Overleaf, wait ~2 s after the last edit,
|
||||
% hover a wavy underline for the message + one-click replacements.
|
||||
% Toggle Settings → Spelling and Language → LanguageTool → "Picky mode"
|
||||
% off/on and compare: section A marks should appear/disappear.
|
||||
|
||||
\documentclass{article}
|
||||
\begin{document}
|
||||
|
||||
\section*{A: Picky-only (missed at default level)}
|
||||
|
||||
% PASSIVE_VOICE_SIMPLE — passive voice
|
||||
The experiment was conducted by our group.
|
||||
|
||||
% PASSIVE_VOICE_SIMPLE — passive voice
|
||||
The error was detected by the test suite.
|
||||
|
||||
\section*{B: Both modes (default and picky)}
|
||||
|
||||
% EN_A_VS_AN — wrong article
|
||||
An sequence off independent variables.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
You can recieve a bonus for this.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
This is a realy interesting result.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
He shoulda told us about the crash.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
The results shows a significant difference.
|
||||
|
||||
% BEEN_PART_AGREEMENT — possible agreement error
|
||||
A new paper was publish last week.
|
||||
|
||||
% THERE_S_MANY — possible agreement error
|
||||
There is three reasons for this choice.
|
||||
|
||||
% IT_IS — possible typo (missing apostrophe)
|
||||
Its time to finish the manuscript.
|
||||
|
||||
% AFFECT_EFFECT — commonly confused word
|
||||
This should have an affect on the outcome.
|
||||
|
||||
% LOOSE_LOSE — possible typo
|
||||
You will loose the key if you leave it here.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
You should of checked the configuration.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
It might of happened last night.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
We could of fixed the bug yesterday.
|
||||
|
||||
% INFORMATIONS — nonstandard word
|
||||
We need more informations about this topic.
|
||||
|
||||
% EN_A_VS_AN — wrong article (uncountable)
|
||||
He gave me a advice yesterday morning.
|
||||
|
||||
% ENGLISH_WORD_REPEAT_RULE — word repetition
|
||||
The the method was tested twice.
|
||||
|
||||
% ADMIT_ENJOY_VB — use 'ing'
|
||||
I look forward to hear from you again.
|
||||
|
||||
% TOO_TO — possible typo
|
||||
I want to too participate in this.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
Each of the students have passed the exam.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
The dog which barks is friendly.
|
||||
|
||||
% RB_RB_COMMA — intensifier comma
|
||||
We have a very very good result here.
|
||||
|
||||
% FREE_GIFT — redundant phrase
|
||||
This is a free gift for all of you.
|
||||
|
||||
% ADMIT_ENJOY_VB — use 'ing'
|
||||
The manual recommends to restart the server.
|
||||
|
||||
% ALLOW_TO — use a gerund
|
||||
The tool allows to configure the output.
|
||||
|
||||
% ALLOW_TO — use a gerund
|
||||
The server requires to restart after the change.
|
||||
|
||||
\end{document}
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose up -d
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose up -d languagetool
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
backup_seaweedfs_online
|
||||
|
||||
workbench
|
||||
@@ -0,0 +1,9 @@
|
||||
docker network create overleaf-network
|
||||
snetz=`docker network inspect overleaf-network | grep "Subnet" | sed s/" "/""/g | sed s/"\,"/""/g | sed s/":"/"\n"/g | grep -v "Subnet" | sed s/'"'/''/g | sed s/"{"/""/g`
|
||||
nid=`docker network ls | grep overleaf-network | awk '{print $1}'`
|
||||
|
||||
ufw allow in on br-$nid
|
||||
ufw route allow in on br-$nid
|
||||
ufw route allow out on br-$nid
|
||||
iptables -t nat -A POSTROUTING ! -o br-$nid -s $snetz -j MASQUERADE
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export AWS_ACCESS_KEY_ID
|
||||
export AWS_SECRET_ACCESS_KEY
|
||||
|
||||
ENDPOINT="http://localhost:8333"
|
||||
|
||||
echo "Testing connection to SeaweedFS S3 gateway..."
|
||||
aws --endpoint-url "$ENDPOINT" s3 ls
|
||||
|
||||
# Array of required Overleaf buckets
|
||||
BUCKETS=(
|
||||
"overleaf-user-files"
|
||||
"overleaf-template-files"
|
||||
"overleaf-project-blobs"
|
||||
"overleaf-chunks"
|
||||
)
|
||||
|
||||
echo "Checking and creating Overleaf buckets..."
|
||||
for bucket in "${BUCKETS[@]}"; do
|
||||
if ! aws --endpoint-url "$ENDPOINT" s3 ls "s3://$bucket" >/dev/null 2>&1; then
|
||||
echo "Creating bucket: s3://$bucket"
|
||||
aws --endpoint-url "$ENDPOINT" s3 mb "s3://$bucket"
|
||||
else
|
||||
echo "Bucket already exists: s3://$bucket"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Bucket setup complete. Current buckets:"
|
||||
aws --endpoint-url "$ENDPOINT" s3 ls
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# 0_ensure_local_auth_type.sh — manage wakapi's users-table normalization
|
||||
# (wakapi/ensure_local_auth_type.sql: trigger + one-time backfill).
|
||||
#
|
||||
# MODES
|
||||
# preflight Drop the trigger (no-op if absent / if the table doesn't
|
||||
# exist yet). Run BEFORE starting or recreating wakapi.
|
||||
# apply Wait for wakapi to serve, then (re)apply trigger + backfill.
|
||||
# Run AFTER wakapi is up. This is the default mode.
|
||||
#
|
||||
# WHY (twice-learned rule, 2026-10-26):
|
||||
# A trigger referencing `users` BLOCKS wakapi's pending startup DDL —
|
||||
# `ERROR: cannot alter type of a column used in a trigger definition
|
||||
# (SQLSTATE 0A000)` — and wakapi crashloops until the trigger is gone.
|
||||
# The batches are recorded per-run; while any of them is unrecorded, EVERY
|
||||
# startup re-runs the batch and can hit the collision. So the only safe
|
||||
# cycle is: drop -> wakapi up (batch runs free, records) -> apply.
|
||||
# up.sh and up_wakapi.sh do exactly this. Rows written during the short
|
||||
# window without the trigger are covered by the apply-time backfill.
|
||||
#
|
||||
# The SQL itself is idempotent (create or replace / drop if exists /
|
||||
# guarded updates), so the dance costs a few SQL statements per `up`.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
MODE="${1:-apply}"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
set -a; . ./.env; set +a
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
PGCLIENT="docker run --rm --network overleaf-network -e PGPASSWORD=${PG_PASSWORD}
|
||||
-e PGUSER=${WAKAPI_DB_USER:-wakapi} -e PGDATABASE=${WAKAPI_DB_NAME:-wakapi}
|
||||
${POSTGRES_IMAGE}"
|
||||
|
||||
wait_pg() {
|
||||
for i in $(seq 1 60); do
|
||||
${PGCLIENT} sh -c 'pg_isready -h wakapi-pg -p 5432' >/dev/null 2>&1 && return 0
|
||||
sleep 2
|
||||
done
|
||||
die "wakapi-pg not accepting connections after 120s"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
preflight)
|
||||
echo "[preflight/1] waiting for wakapi-pg (needed to drop the trigger)..."
|
||||
wait_pg
|
||||
echo "[preflight/2] dropping normalization trigger (if any) so wakapi startup DDL can run..."
|
||||
${PGCLIENT} psql -h wakapi-pg -p 5432 -v ON_ERROR_STOP=1 -c \
|
||||
"do \$\$ begin execute 'drop trigger if exists trg_wakapi_normalize_local_auth on users'; exception when undefined_object then null; end \$\$;" \
|
||||
>/dev/null
|
||||
echo "[preflight] done (no-op if the trigger wasn't there)"
|
||||
;;
|
||||
apply)
|
||||
echo "[1/3] waiting for wakapi-pg to accept connections..."
|
||||
wait_pg
|
||||
|
||||
echo "[2/3] waiting for users table + wakapi serving (startup migrations complete)..."
|
||||
READY=0
|
||||
for i in $(seq 1 90); do
|
||||
TBL=$( ${PGCLIENT} psql -h wakapi-pg -p 5432 -tAq -c "select to_regclass('public.users')")
|
||||
if [ "$TBL" = "users" ] \
|
||||
&& docker run --rm --network overleaf-network ${PROBE_IMAGE} \
|
||||
wget -q --spider http://wakapi:3000/login >/dev/null 2>&1; then
|
||||
READY=1; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
[ "$READY" = "1" ] \
|
||||
|| die "users table or wakapi not ready/serving after 180s — check: bash logs_wakapi.sh (look for 'migration failed')"
|
||||
|
||||
echo "[3/3] applying wakapi/ensure_local_auth_type.sql ..."
|
||||
docker run --rm --network overleaf-network \
|
||||
-e PGPASSWORD="${PG_PASSWORD}" \
|
||||
-e PGUSER="${WAKAPI_DB_USER:-wakapi}" \
|
||||
-e PGDATABASE="${WAKAPI_DB_NAME:-wakapi}" \
|
||||
-v "$(pwd)/wakapi/ensure_local_auth_type.sql:/init.sql:ro" \
|
||||
${POSTGRES_IMAGE} \
|
||||
psql -h wakapi-pg -p 5432 -v ON_ERROR_STOP=1 -f /init.sql
|
||||
|
||||
echo "active trigger(s) on users:"
|
||||
${PGCLIENT} psql -h wakapi-pg -p 5432 -tAq \
|
||||
-c "select ' '||tgname from pg_trigger where tgrelid='public.users'::regclass and not tgisinternal order by 1"
|
||||
echo "OK — local-auth normalization is active (staff 'Forgot password?' path works)."
|
||||
;;
|
||||
*)
|
||||
die "unknown mode '$MODE' (expected: preflight | apply)"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,2 @@
|
||||
sudo apt update
|
||||
sudo apt install -y awscli
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Path to your .env file (adjust if your script runs from a different directory)
|
||||
ENV_FILE="./.env"
|
||||
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found at $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure AWS variables are exported for the AWS CLI
|
||||
export AWS_ACCESS_KEY_ID
|
||||
export AWS_SECRET_ACCESS_KEY
|
||||
|
||||
ENDPOINT="http://localhost:8333"
|
||||
BACKUP_DIR="./backup_seaweedfs_online"
|
||||
TIMESTAMP=$(date +%F_%H-%M-%S)
|
||||
|
||||
mkdir -p "$BACKUP_DIR/$TIMESTAMP"
|
||||
|
||||
echo "1. Capturing online metadata snapshot..."
|
||||
docker exec seaweedfs-filer weed shell -master="master:9333" <<< "fs.meta.save /data/filer_meta_$TIMESTAMP.json"
|
||||
|
||||
echo "2. Syncing S3 buckets live..."
|
||||
for bucket in overleaf-user-files overleaf-template-files overleaf-project-blobs overleaf-chunks; do
|
||||
echo "Syncing s3://$bucket..."
|
||||
aws --endpoint-url "$ENDPOINT" s3 sync "s3://$bucket" "$BACKUP_DIR/$TIMESTAMP/$bucket"
|
||||
done
|
||||
|
||||
echo "Online backup completed successfully at $TIMESTAMP"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker pull ${TEX_LIVE_REPO}/texlive-full:2026.1
|
||||
docker pull ${TEX_LIVE_REPO}/texlive-full:2025.1
|
||||
docker pull ${TEX_LIVE_REPO}/texlive-full:2024.1
|
||||
docker pull ${TEX_LIVE_REPO}/texlive-full:2023.1
|
||||
|
||||
docker pull nginx:stable-alpine
|
||||
docker pull mongo:${MONGO_VERSION}
|
||||
docker pull redis:${REDIS_VERSION}
|
||||
docker pull chrislusf/seaweedfs:latest
|
||||
@@ -0,0 +1,180 @@
|
||||
# Overleaf CE — self-hosted stack (`psintern.neuro.uni-bremen.de`)
|
||||
|
||||
One Docker Compose project that runs the entire server: Overleaf CE (languagetool CE
|
||||
fork), its storage backend (MongoDB + Redis + SeaweedFS/S3), Git sync (git-bridge),
|
||||
grammar checking (LanguageTool), WakaTime-style activity tracking (Wakapi), and the
|
||||
TLS edge (nginx). Everything runs in the shared external network **`overleaf-network`**.
|
||||
|
||||
- Public URL: `https://psintern.neuro.uni-bremen.de` (Overleaf)
|
||||
- Wakapi UI: `https://psintern.neuro.uni-bremen.de/wakapi/` (same host, nginx subpath)
|
||||
- Server source: **not this repo** — the Overleaf image is built from the
|
||||
`overleaf-cep` checkout (`ext-ce-v6.3.1` branch); this directory only *deploys*.
|
||||
|
||||
---
|
||||
|
||||
## 1. Layout
|
||||
|
||||
```
|
||||
compose.yaml # root project: composes all services below via `extends`,
|
||||
# adds depends_on ordering, declares the external network
|
||||
<service>/compose.yaml # one service per directory (see table)
|
||||
.env # ALL configuration incl. secrets — git-ignored, never commit
|
||||
env.sample # committed, secret-free template of .env (cp env.sample .env)
|
||||
0_*.sh # one-time setup (run once, in this order)
|
||||
up_*.sh down_*.sh # per-service lifecycle (they cd to the project root themselves)
|
||||
logs_*.sh exec_*.sh # per-service logs / shell
|
||||
autoupdate_*.sh # pull next image version for a service (then up_*.sh)
|
||||
cycle_{nginx,overleaf}.sh
|
||||
0_ensure_local_auth_type.sh # wakapi users-table normalization trigger, 2 modes:
|
||||
# preflight — drop trigger (BEFORE wakapi up, unblocks its DDL)
|
||||
# apply — trigger + backfill (AFTER wakapi serves, default)
|
||||
# Both run automatically from up.sh / up_wakapi.sh.
|
||||
# Manual: bash 0_ensure_local_auth_type.sh [preflight|apply]
|
||||
up.sh / down.sh # the whole project (up.sh first auto-pins OVERLEAF_IP, see §5)
|
||||
<service>/data* # live data volumes (mongo, redis, seaweedfs-*, overleaf, ...) — git-ignored
|
||||
<service>/backup/ # backup dumps (git-ignored)
|
||||
backup_seaweedfs_online/# timestamped SeaweedFS online backups (git-ignored)
|
||||
<service>/backup.sh # mongo/, wakapi-pg/: dump into <service>/backup/
|
||||
```
|
||||
|
||||
### Services
|
||||
|
||||
| dir | container | image (env pin) | role |
|
||||
|---|---|---|---|
|
||||
| `overleaf/` | `overleaf` | `sharelatex/sharelatex:${OVERLEAF_VERSION}` | the app (CE fork). `./data` = /var/lib/overleaf, `./log` = /var/log/overleaf, docker.sock mounted |
|
||||
| `mongo/` | `mongo` | `mongo:${MONGO_VERSION}` | documents/projects. Runs as replica set `overleaf`. `./data_db`, `./backup` |
|
||||
| `redis/` | `redis` | `redis:${REDIS_VERSION}` | sessions/queues. AOF persistence. `./data`, `./backup` |
|
||||
| `seaweedfs-{master,volume,filer,s3}/` | same | `chrislusf/seaweedfs:latest` | S3-compatible file storage for overleaf (compiles, uploads, exports). Host ports bound to 127.0.0.1 only (9333/19333 master, 18080 volume gRPC, 8888 filer, 8333 S3 API). In-network aliases `master`, `volume`, `filer`, `s3` |
|
||||
| `git-bridge/` | `git-bridge` | `quay.io/sharelatex/git-bridge:${GIT_BRIDE_VERSION}` | Git export/sync for projects (texlive repo `TEX_LIVE_REPO`) |
|
||||
| `languagetool/` | `languagetool` | `erikvl87/languagetool:latest` | grammar checking (enabled in overleaf: `LANGUAGE_TOOL_*`); ngram models in `./ngrams` |
|
||||
| `wakapi-pg/` | `wakapi-pg` | `postgres:${POSTGRES_IMAGE}` | Wakapi's database (db/user `wakapi`, pw `${PG_PASSWORD}`). `./data`, `./backup` |
|
||||
| `wakapi/` | `wakapi` | `${WAKA_API_IMAGE}` (stock ghcr or local build) | WakaTime-compatible time tracking, self-hosted |
|
||||
| `wakapi-probe/` | `wakapi-probe` | `${PROBE_IMAGE}` (busybox, `sleep infinity`) | nothing it runs — a *healthcheck-only* container: "HTTP serving" proof for wakapi; nginx depends on it being healthy |
|
||||
| `nginx/` | `nginx` | `nginx:stable-alpine` | TLS edge (Let's Encrypt files from `SSL_KEY_FILE`/`SSL_CERT_FILE`), ACME dir, serves Overleaf at `/` and Wakapi at `/wakapi/` |
|
||||
|
||||
Startup order (enforced by `depends_on: service_healthy` in the root file):
|
||||
`redis, mongo, seaweedfs-* → overleaf → nginx`; `wakapi-pg → wakapi → wakapi-probe → nginx`;
|
||||
`git-bridge`, `languagetool` independent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuration (`.env`)
|
||||
|
||||
- **`env.sample` is the source of truth for *what keys exist* and what they mean** —
|
||||
copy it (values included where safe), then fill the `<PLACEHOLDER>`s. `.env` is
|
||||
git-ignored; **never** commit it, and **never** paste values from it into
|
||||
`env.sample` (it's the template, not a backup).
|
||||
- Plain `KEY=value` lines. Compose interpolates `${OTHER_KEY}` between keys, but
|
||||
**no shell**: `$(docker ...)`, backticks, `;` do **not** run — a line like
|
||||
`FOO=$(cmd)` stores the literal string. (Wakapi, e.g., would then warn and drop it.)
|
||||
- Secrets in play: AWS S3 keys (SeaweedFS S3 API), Zotero app creds, GitHub App
|
||||
creds, Keycloak OIDC client, invite-token secret, Overleaf SMTP password,
|
||||
wakapi-pg password. Generator hints are in the comments.
|
||||
- `OVERLEAF_IP` is **auto-maintained**: `up.sh` / `up_wakapi.sh` rewrite it from
|
||||
`docker inspect overleaf` before bringing (re)created containers up. Don't fight it.
|
||||
|
||||
---
|
||||
|
||||
## 3. First-time setup order
|
||||
|
||||
```bash
|
||||
bash 0_create_network.sh # overleaf-network + ufw/iptables for it
|
||||
bash 0_install_aws.sh # aws cli (for bucket ops)
|
||||
bash 0_create_s3_buckets.sh # seaweedfs S3 buckets for overleaf
|
||||
bash 0_pull_other_containers.sh # image pulls
|
||||
cp env.sample .env && vi .env # fill secrets
|
||||
bash up.sh # everything (pins OVERLEAF_IP first)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Day-to-day
|
||||
|
||||
```bash
|
||||
bash up_<svc>.sh # start/recreate one service (mongo, redis, overleaf, nginx,
|
||||
# wakapi, wakapi-pg, wakapi-probe, seaweedfs-*, git-bridge, languagetool)
|
||||
bash down_<svc>.sh # stop+remove it
|
||||
bash logs_<svc>.sh # follow logs
|
||||
bash exec_<svc>.sh # interactive shell (where the image allows)
|
||||
bash cycle_nginx.sh # down+up nginx (config change, cert renewal)
|
||||
bash up.sh # whole project
|
||||
# backups
|
||||
bash mongo/backup.sh # mongodump -> mongo/backup/
|
||||
bash wakapi-pg/backup.sh # pg_dump -> wakapi-pg/backup/
|
||||
# see backup_seaweedfs_online/ + 0_online_backup_s3.sh for the seaweedfs side
|
||||
```
|
||||
|
||||
**Image upgrades:** bump the `*_VERSION` / `WAKA_API_IMAGE` pins in `.env`
|
||||
(or edit the `image:` line in the service's compose.yaml), then `bash up_<svc>.sh` —
|
||||
compose replaces the container. `autoupdate_*.sh` do the pin-bump for
|
||||
mongo/redis/nginx/languagetool/seaweedfs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Special behaviors that are easy to step on
|
||||
|
||||
- **Overleaf rebuilds**: the image comes from the `overleaf-cep` checkout
|
||||
(`ext-ce-v6.3.1`). Rebuild there (see that repo's `server-ce/` + the
|
||||
`rebuild-overleaf-docker-image` skill), tag/push per `.env`'s `OVERLEAF_VERSION`,
|
||||
then `bash up_overleaf.sh`. A plain `docker compose up` does **not** swap a
|
||||
same-tag image unless pulled/retagged.
|
||||
- **Wakapi identity model** (no external signup, no per-user keys):
|
||||
- `WAKAPI_ALLOW_SIGNUP=false`, trusted-header auth on: Overleaf (the **only** trusted
|
||||
source IP — `WAKAPI_TRUST_REVERSE_PROXY_IPS` = `OVERLEAF_IP`) asserts the logged-in
|
||||
user's email in `Remote-User`; wakapi auto-creates/identifies the user.
|
||||
Hostname values in that list are **silently dropped** by wakapi (IP/CIDR only).
|
||||
- Staff get a UI password **self-service**: `…/wakapi/login` → "Forgot password?"
|
||||
→ reset mail via the university relay (`OVERLEAF_EMAIL_*`, sender
|
||||
`overleaf@uni-bremen.de`) → set password. Requires `WAKAPI_PUBLIC_URL`
|
||||
(`${OVERLEAF_SITE_URL}/wakapi` — the ship default `http://localhost:3000` would
|
||||
mail useless links).
|
||||
- **Normalization trigger for trusted-header users** (kept in the DB, not in
|
||||
wakapi): `wakapi/ensure_local_auth_type.sql` — a PG trigger on `wakapi-pg`
|
||||
rewriting header-created rows to `auth_type='local'` + `email=id`, else
|
||||
wakapi refuses the "Forgot password?" reset (routes/login.go).
|
||||
**Fully automatic safe cycle** (see `0_ensure_local_auth_type.sh` header):
|
||||
`up.sh` / `up_wakapi.sh` first run `preflight` (drop the trigger if present)
|
||||
so wakapi's startup DDL can never be blocked by it, start wakapi (pending
|
||||
batches run free and record), then `apply` (trigger + backfill — the
|
||||
backfill also covers rows written in the short unpinned window). This is
|
||||
mandatory while **any** wakapi migration batch is unrecorded: then every
|
||||
startup re-runs the batch and hits the trigger (`SQLSTATE 0A000`, the
|
||||
2026-10-26 crashloop). Once all batches are recorded the drop/apply dance
|
||||
remains a cheap no-cost safety net (e.g. after wipes or wakapi upgrades).
|
||||
**Only if you start wakapi manually** outside those scripts
|
||||
(`docker compose up -d wakapi`), run the preflight first:
|
||||
`bash 0_ensure_local_auth_type.sh preflight`
|
||||
- **Wakapi base path**: the app serves at `/` and only shapes *generated* URLs with
|
||||
`WAKAPI_BASE_PATH=/wakapi`. That's why nginx does
|
||||
`location /wakapi/ { proxy_pass http://wakapi:3000/; }` — the **trailing slash**
|
||||
strips the prefix. Removing it 404s the UI.
|
||||
- **nginx strips `Remote-User` on browser traffic** (`proxy_set_header Remote-User ""`)
|
||||
so clients cannot inject identities; only the overleaf container (pinned IP) may.
|
||||
- **mongo** runs `--replSet overleaf` with the init-in-healthcheck trick, and
|
||||
`GLIBC_TUNABLES: glibc.cpu.hwcaps=-SHSTK` (CPU feature-hole workaround — don't
|
||||
"clean" it).
|
||||
- **seaweedfs**: four cooperating containers; in-network names are the classic
|
||||
`master/filer/volume/s3` (aliases), host ports are loopback-only. The volume
|
||||
deliberately does **not** publish 8080 to the host (collision) — keep it that way.
|
||||
- **Health is load-bearing**: `nginx` waits for `overleaf` + `wakapi-probe`
|
||||
(healthchecks) before being healthy; `wakapi-probe` is an idle busybox whose
|
||||
healthcheck is the wakapi HTTP probe.
|
||||
- **UFW/iptables**: `0_create_network.sh` opens the bridge interface — after
|
||||
reboots or network recreation, make sure these rules still exist.
|
||||
- **Cold start fails with `Conflict. The container name "/<svc>" is already in
|
||||
use`**: an orphan container from another project (e.g. the old
|
||||
`tools/*_server_compose` recipes) still holds the name and `down.sh` didn't
|
||||
remove it (it's not this project's). Find it: `docker ps -a --filter name=<svc>`,
|
||||
then `docker rm <name>` (volumes, if any, stay on disk) and re-run `up.sh`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Related locations
|
||||
|
||||
- Overleaf server source & build: `overleaf-cep` checkout, branch `ext-ce-v6.3.1`
|
||||
(modules: `services/web/modules/{wakime,zotero,track-changes,…}`)
|
||||
- Wakapi upstream source checkout (for template/feature work against a pinned tag):
|
||||
`/data_1/compare/wakapi` (has tags incl. `2.18.2`)
|
||||
- Skills with the muscle memory for this box:
|
||||
`overleaf-dev-server-testing`, `rebuild-overleaf-docker-image`,
|
||||
`overleaf-agent-workflows`
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_NAME="languagetool"
|
||||
IMAGE_NAME="erikvl87/languagetool:latest"
|
||||
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="mongo"
|
||||
IMAGE_NAME="mongo:${MONGO_VERSION}"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="nginx"
|
||||
IMAGE_NAME="nginx:stable-alpine"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="redis"
|
||||
IMAGE_NAME="redis:${REDIS_VERSION}"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-filer"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-master"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-s3"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-volume"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user