feat/wakatime integration- #249 plus tools/reference_stack_compose with wakapi

This commit is contained in:
David Rotermund
2026-10-27 00:20:07 +00:00
parent a99a12887c
commit 6f2cf1c060
123 changed files with 3344 additions and 4 deletions
@@ -425,6 +425,8 @@ 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:
+29
View File
@@ -1039,6 +1039,10 @@ module.exports = {
__dirname,
'../modules/languagetool/frontend/js/languagetool-extension'
),
Path.resolve(
__dirname,
'../modules/wakatime/frontend/js/editor/wakatime-source-editor-extension.ts'
),
],
sourceEditorVisualExtensions: [],
sourceEditorComponents: [],
@@ -1083,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(
@@ -1177,6 +1185,10 @@ 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: [],
@@ -1218,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: {
@@ -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'
}
@@ -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
@@ -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)
}
+13
View File
@@ -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
+1
View File
@@ -56,6 +56,7 @@ export type ExposedSettings = {
linkedInInsightsPartnerId?: string
githubSyncEnabled: boolean
zoteroEnabled: boolean
wakaTimeEnabled: boolean
languageToolAvailable: boolean
enablePandocConversions: boolean
}
+4
View File
@@ -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
+89
View File
@@ -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
View File
@@ -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"
+22
View File
@@ -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
+180
View File
@@ -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`
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+44
View File
@@ -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
+102
View File
@@ -0,0 +1,102 @@
services:
redis:
extends:
file: ./redis/compose.yaml
service: redis
mongo:
extends:
file: ./mongo/compose.yaml
service: mongo
seaweedfs-master:
extends:
file: ./seaweedfs-master/compose.yaml
service: seaweedfs-master
seaweedfs-volume:
extends:
file: ./seaweedfs-volume/compose.yaml
service: seaweedfs-volume
depends_on:
seaweedfs-master:
condition: service_healthy
seaweedfs-filer:
extends:
file: ./seaweedfs-filer/compose.yaml
service: seaweedfs-filer
depends_on:
seaweedfs-master:
condition: service_healthy
seaweedfs-volume:
condition: service_healthy
seaweedfs-s3:
extends:
file: ./seaweedfs-s3/compose.yaml
service: seaweedfs-s3
depends_on:
seaweedfs-filer:
condition: service_healthy
git-bridge:
extends:
file: ./git-bridge/compose.yaml
service: git-bridge
languagetool:
extends:
file: ./languagetool/compose.yaml
service: languagetool
overleaf:
extends:
file: ./overleaf/compose.yaml
service: overleaf
depends_on:
redis:
condition: service_healthy
mongo:
condition: service_healthy
seaweedfs-s3:
condition: service_healthy
git-bridge:
condition: service_healthy
languagetool:
condition: service_healthy
wakapi-pg:
extends:
file: ./wakapi-pg/compose.yaml
service: wakapi-pg
wakapi:
extends:
file: ./wakapi/compose.yaml
service: wakapi
depends_on:
wakapi-pg:
condition: service_healthy
wakapi-probe:
extends:
file: ./wakapi-probe/compose.yaml
service: wakapi-probe
depends_on:
wakapi:
condition: service_healthy
nginx:
extends:
file: ./nginx/compose.yaml
service: nginx
depends_on:
overleaf:
condition: service_healthy
wakapi-probe:
condition: service_healthy
networks:
overleaf-network:
external: true
+3
View File
@@ -0,0 +1,3 @@
docker compose down nginx
docker compose up -d nginx
+3
View File
@@ -0,0 +1,3 @@
docker compose down overleaf
docker compose up -d overleaf
+2
View File
@@ -0,0 +1,2 @@
docker compose down
@@ -0,0 +1,2 @@
docker compose down git-bridge
@@ -0,0 +1,3 @@
docker compose down languagetool
@@ -0,0 +1,2 @@
docker compose down mongo
@@ -0,0 +1,2 @@
docker compose down nginx
@@ -0,0 +1,2 @@
docker compose down overleaf
@@ -0,0 +1,2 @@
docker compose down redis
@@ -0,0 +1,2 @@
docker compose down seaweedfs-filer
@@ -0,0 +1,2 @@
docker compose down seaweedfs-master
@@ -0,0 +1,2 @@
docker compose down seaweedfs-s3
@@ -0,0 +1,2 @@
docker compose down seaweedfs-volume
@@ -0,0 +1,2 @@
docker compose down wakapi-pg
@@ -0,0 +1,2 @@
docker compose down wakapi-probe
@@ -0,0 +1,2 @@
docker compose down wakapi
+105
View File
@@ -0,0 +1,105 @@
# ============================================================================
# env.sample — template for /data_1/docker/compose/.env
#
# HOW TO USE: cp env.sample .env then fill in the <PLACEHOLDER> values.
# .env is git-ignored and is the ONLY place where real secrets live; compose
# injects these into the containers (no shell expansion in .env values —
# ${VAR} references work between keys, $(cmd) does NOT).
#
# NOTE: some values are auto-maintained (marked below) — do not hand-edit
# those or the change will be overwritten on the next up.
#
# THIS FILE CONTAINS NO SECRETS (it is committed). Never copy real values
# from .env into here.
# ============================================================================
# Image versions
OVERLEAF_VERSION=ext-ce-v6.3.1
MONGO_VERSION=9.0
REDIS_VERSION=8.10-alpine
GIT_BRIDE_VERSION=6.3.1
# Global
# You need here the correct path (the compose.yaml is in /docker/compose_cep/overleaf)
COMPOSE_DIRECTORY=/data_1/docker/compose
TEX_LIVE_REPO=git.fb1.uni-bremen.de/davrot
OVERLEAF_LEFT_FOOTER='[]'
OVERLEAF_LOGIN_SUPPORT_TEXT="<img src=\"ub/logo.svg\" height=\"250\"></br><a href=\"https://sso.fb1.uni-bremen.de\"> University of Bremen Overleaf Wiki </a>"
COMPILES_HOST_DIR=${COMPOSE_DIRECTORY}/overleaf/data/data/compiles
COMPILES_HOST_DIR_CACHE=${COMPOSE_DIRECTORY}/overleaf/data/data/cache
CONVERSION_SANDBOX_HOST_DIR=${COMPOSE_DIRECTORY}/overleaf/data/tmp/conversions
OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE=davrot@uni-bremen.de
# Nginx SSL
SSL_KEY_FILE=/etc/letsencrypt/live/psintern.neuro.uni-bremen.de/privkey.pem
SSL_CERT_FILE=/etc/letsencrypt/live/psintern.neuro.uni-bremen.de/fullchain.pem
#S3 Buckets
# openssl rand -base64 30 | tr -dc 'a-zA-Z0-9' | head -c 20; echo
AWS_ACCESS_KEY_ID=<AWS_ACCESS_KEY_ID>
# openssl rand -base64 60 | tr -dc 'a-zA-Z0-9' | head -c 40; echo
AWS_SECRET_ACCESS_KEY=<AWS_SECRET_ACCESS_KEY>
# Zotero
# (credentials from your Zotero account: Settings -> Advanced -> Credentials)
ZOTERO_CLIENT_KEY=<ZOTERO_CLIENT_KEY>
ZOTERO_CLIENT_SECRET=<ZOTERO_CLIENT_SECRET>
# Github
# (from your GitHub App settings)
GITHUB_SYNC_CLIENT_ID=<GITHUB_SYNC_CLIENT_ID>
GITHUB_SYNC_CLIENT_SECRET=<GITHUB_SYNC_CLIENT_SECRET>
# openssl rand -hex 32; echo
OVERLEAF_INVITE_TOKEN_SECRET=<OVERLEAF_INVITE_TOKEN_SECRET>
# Keycloak OpenID Connect Configuration
OVERLEAF_OIDC_ISSUER=https://sso.fb1.uni-bremen.de/sso/realms/master
# (from your SSO realm / client registration)
OVERLEAF_OIDC_CLIENT_ID=<OVERLEAF_OIDC_CLIENT_ID>
OVERLEAF_OIDC_CLIENT_SECRET=<OVERLEAF_OIDC_CLIENT_SECRET>
OVERLEAF_OIDC_IDENTITY_SERVICE_NAME='Log in with SSO FB1 Uni Bremen'
# eMail Account Configuration
# (SMTP password for the user in OVERLEAF_EMAIL_SMTP_USER)
OVERLEAF_EMAIL_PASSWORD=<OVERLEAF_EMAIL_PASSWORD>
OVERLEAF_EMAIL_FROM_ADDRESS=overleaf@uni-bremen.de
OVERLEAF_EMAIL_SMTP_HOST=smtp.uni-bremen.de
OVERLEAF_EMAIL_SMTP_PORT=465
OVERLEAF_EMAIL_SMTP_SECURE=true
OVERLEAF_EMAIL_SMTP_USER=overleaf
OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
# Other Overleaf Configurations Configuration
OVERLEAF_SITE_URL=https://psintern.neuro.uni-bremen.de
OVERLEAF_ADMIN_EMAIL=overleaf@uni-bremen.de
OVERLEAF_APP_NAME="University of Bremen -- Overleaf"
OVERLEAF_NAV_TITLE="University of Bremen -- Overleaf"
OVERLEAF_CUSTOM_EMAIL_FOOTER="University of Bremen -- Overleaf"
##########
# Wakapi #
##########
# openssl rand -base64 30 | tr -dc "a-zA-Z0-9" | head -c 24; echo
PG_PASSWORD=<PG_PASSWORD>
# HOST port for the wakapi web UI + API (3000 by convention; use 3001 if taken)
WAKA_API_PORT=3000
# image pins (bump + autoupdate on upgrade)
WAKA_API_IMAGE=ghcr.io/muety/wakapi:2.18.2
POSTGRES_IMAGE=postgres:18-alpine
PROBE_IMAGE=busybox:1.36
# overleaf container IP on this network (WAKAPI_TRUST_REVERSE_PROXY_IPS)
# — AUTO-MAINTAINED: up.sh / up_wakapi.sh rewrite this line from
# `docker inspect overleaf` on every (re)start-adjacent run. Leave empty:
OVERLEAF_IP=
# wakapi SMTP implicit TLS (smtp.uni-bremen.de:465)
WAKAPI_MAIL_SMTP_TLS=true
+1
View File
@@ -0,0 +1 @@
docker exec -it git-bridge sh
+1
View File
@@ -0,0 +1 @@
docker exec -it mongo sh
+1
View File
@@ -0,0 +1 @@
docker exec -it nginx sh
+1
View File
@@ -0,0 +1 @@
docker exec -it overleaf sh
+1
View File
@@ -0,0 +1 @@
docker exec -it redis sh
+1
View File
@@ -0,0 +1 @@
docker exec -it seaweedfs-filer sh
+1
View File
@@ -0,0 +1 @@
docker exec -it seaweedfs-master sh
+1
View File
@@ -0,0 +1 @@
docker exec -it seaweedfs-s3 sh
+1
View File
@@ -0,0 +1 @@
docker exec -it seaweedfs-volume sh
+1
View File
@@ -0,0 +1 @@
docker exec -it wakapi-pg sh
+1
View File
@@ -0,0 +1 @@
docker exec -it wakapi-probe sh
+1
View File
@@ -0,0 +1 @@
docker exec -it wakapi sh
@@ -0,0 +1 @@
data
@@ -0,0 +1,34 @@
services:
git-bridge:
restart: always
image: quay.io/sharelatex/git-bridge:${GIT_BRIDE_VERSION}
volumes:
- ./data:/data/git-bridge
container_name: git-bridge
hostname: git-bridge
expose:
- "8000"
environment:
GIT_BRIDGE_API_BASE_URL: "http://overleaf:3000/api/v0/"
GIT_BRIDGE_OAUTH2_SERVER: "http://overleaf"
GIT_BRIDGE_POSTBACK_BASE_URL: "http://git-bridge:8000"
GIT_BRIDGE_ROOT_DIR: "/data/git-bridge"
user: root
command: ["/server-pro-start.sh"]
healthcheck:
# /status is git-bridge's liveness route (200 'ok'); the root path
# needs an Overleaf session (500) and /healthz wants auth (401).
test: ["CMD-SHELL", "wget -q --spider http://localhost:8000/status >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
networks:
- overleaf-network
networks:
overleaf-network:
external: true
@@ -0,0 +1 @@
ngrams
@@ -0,0 +1,31 @@
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
networks:
- overleaf-network
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
networks:
overleaf-network:
external: true
@@ -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"
@@ -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,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}
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f git-bridge
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f languagetool
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f mongo
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f nginx
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f overleaf
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f redis
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f seaweedfs-filer
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f seaweedfs-master
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f seaweedfs-s3
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f seaweedfs-volume
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f wakapi-pg
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f wakapi-probe
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f wakapi
@@ -0,0 +1,2 @@
backup
data_db
+1
View File
@@ -0,0 +1 @@
docker exec mongo bash -c "mongodump --out /backup/"
@@ -0,0 +1,26 @@
services:
mongo:
image: mongo:${MONGO_VERSION}
container_name: mongo
hostname: mongo
restart: always
healthcheck:
test: "mongosh --quiet --eval 'rs.hello().setName ? rs.hello().setName : rs.initiate({_id: \"overleaf\",members:[{_id: 0, host:\"mongo:27017\"}]})'"
interval: 10s
timeout: 10s
retries: 5
command: "--replSet overleaf"
environment:
GLIBC_TUNABLES: glibc.cpu.hwcaps=-SHSTK
volumes:
- ./data_db:/data/db
- ./backup:/backup
networks:
- overleaf-network
extra_hosts:
- "mongo:127.0.0.1"
networks:
overleaf-network:
external: true
@@ -0,0 +1,37 @@
services:
nginx:
image: nginx:stable-alpine
container_name: nginx
hostname: nginx
restart: always
volumes:
- ${SSL_KEY_FILE}:/certs/nginx_key.pem:ro
- ${SSL_CERT_FILE}:/certs/nginx_certificate.pem:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- /var/www/acme:/var/www/acme:ro
- ./static:/var/www/static
ports:
- "0.0.0.0:443:443"
- "0.0.0.0:80:80"
- ":::443:443"
- ":::80:80"
environment:
NGINX_WORKER_PROCESSES: "4"
NGINX_WORKER_CONNECTIONS: "768"
networks:
- overleaf-network
healthcheck:
test: ["CMD-SHELL", "curl -fsI --connect-timeout 10 http://localhost || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
networks:
overleaf-network:
external: true
@@ -0,0 +1,71 @@
events {}
http {
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# ACME challenge — must come before the redirect
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
# Everything else redirects to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /certs/nginx_certificate.pem;
ssl_certificate_key /certs/nginx_key.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains;";
server_tokens off;
client_max_body_size 50M;
location / {
proxy_pass http://overleaf:80;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3m;
proxy_send_timeout 3m;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-store" always;
}
location /ub/ {
alias /var/www/static/ub/;
try_files $uri =404;
types {
image/svg+xml svg;
}
}
# Bundled Wakapi (WakaTime-compatible server) — UI + API under /wakapi.
# Wakapi 2.18.2 contract: the app serves its routes at / and uses
# WAKAPI_BASE_PATH=/wakapi only for the URLs/cookies it generates —
# so the proxy must STRIP the prefix (trailing slash on proxy_pass):
# /wakapi/login → /login, while its links stay /wakapi/… end-to-end.
# Security: strip any client-supplied identity header — only the overleaf
# container (pinned source IP) may use the trusted-header identity.
# /wakapi (exact) → canonical form with trailing slash
location = /wakapi {
return 301 /wakapi/;
}
location /wakapi/ {
proxy_pass http://wakapi:3000/;
proxy_set_header Remote-User "";
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
}
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 39.761 39.761" width="32" height="32"><path d="M8.184 15.336c0 3.648 3.035 6.551 6.679 6.551 3.649 0 6.684-2.836 6.684-6.551v-13.5h-3.309v13.5c0 1.824-1.484 3.242-3.304 3.242-1.758 0-3.309-1.418-3.309-3.242v-13.5H8.184Z" style="fill:#d7193a;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="scale(1.33333)"/><path d="M24.789 1.836v13.5c0 5.469-4.523 9.855-9.992 9.855-5.535 0-9.988-4.32-9.988-9.855v-13.5H1.5v13.5C1.5 22.695 7.508 28.5 14.797 28.5c7.359 0 13.297-5.805 13.297-13.164v-13.5z" style="fill:#d7193a;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="scale(1.33333)"/></svg>

After

Width:  |  Height:  |  Size: 672 B

@@ -0,0 +1,2 @@
data
log
@@ -0,0 +1,216 @@
services:
overleaf:
image: docker.io/sharelatex/sharelatex:${OVERLEAF_VERSION}
container_name: overleaf
hostname: overleaf
restart: always
volumes:
- ./data:/var/lib/overleaf
- ./log:/var/log/overleaf
- /var/run/docker.sock:/var/run/docker.sock
environment:
GIT_BRIDGE_ENABLED: true
GIT_BRIDGE_HOST: git-bridge
GIT_BRIDGE_PORT: 8000
## LanguageTool grammar check (services/web/modules/languagetool)
## Requires the `languagetool` container reachable on the attached
## `languagetools` network (see tools/languagetools_server_compose).
## Picky level is the default on the client side.
LANGUAGE_TOOL_ENABLED: true
LANGUAGE_TOOL_HOST: languagetool
LANGUAGE_TOOL_PORT: "8010"
## WakaTime/Wakapi activity tracking (PR 249 + bundled Wakapi server)
## — see tools/wakapi_server_compose in the overleaf-cep repo).
## Shared identity (trusted header, muety/wakapi#808): NO per-user
## registration, NO API keys — logged-in users appear in Wakapi
## automatically. The 'wakapi' DNS alias + this env must both be in
## place; re-run tools/wakapi_server_compose/1_attach_overleaf_network.sh
## after every overleaf container recreation.
WAKATIME_SHARED_IDENTITY: true
WAKATIME_SHARED_IDENTITY_API_URL: http://wakapi:3000/api/compat/wakatime/v1
WAKATIME_SHARED_IDENTITY_HEADER: Remote-User
REDIS_HOST: redis
REDIS_PORT: 6379
OVERLEAF_REDIS_HOST: redis
V1_HISTORY_URL: http://overleaf:3100/api
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex
OVERLEAF_APP_NAME: ${OVERLEAF_APP_NAME}
ENABLED_LINKED_FILE_TYPES: project_file,project_output_file,url,zotero
ENABLE_CONVERSIONS: true
EMAIL_CONFIRMATION_DISABLED: true
OVERLEAF_BEHIND_PROXY: true
OVERLEAF_SECURE_COOKIE: true
OVERLEAF_SITE_URL: ${OVERLEAF_SITE_URL}
OVERLEAF_NAV_TITLE: ${OVERLEAF_NAV_TITLE}
NAV_HIDE_POWERED_BY: true
OVERLEAF_ADMIN_EMAIL: ${OVERLEAF_ADMIN_EMAIL}
OVERLEAF_LOGIN_SUPPORT_TEXT: ${OVERLEAF_LOGIN_SUPPORT_TEXT}
SHOW_UNSUPPORT_WARNING: false
ENABLE_LOCAL_LOGIN_MASK: false
########################
## eMail ##
########################
OVERLEAF_EMAIL_FROM_ADDRESS: ${OVERLEAF_EMAIL_FROM_ADDRESS}
OVERLEAF_EMAIL_SMTP_HOST: ${OVERLEAF_EMAIL_SMTP_HOST}
OVERLEAF_EMAIL_SMTP_PORT: ${OVERLEAF_EMAIL_SMTP_PORT}
OVERLEAF_EMAIL_SMTP_SECURE: ${OVERLEAF_EMAIL_SMTP_SECURE}
OVERLEAF_EMAIL_SMTP_USER: ${OVERLEAF_EMAIL_SMTP_USER}
OVERLEAF_EMAIL_SMTP_PASS: ${OVERLEAF_EMAIL_PASSWORD}
OVERLEAF_EMAIL_SMTP_IGNORE_TLS: ${OVERLEAF_EMAIL_SMTP_IGNORE_TLS}
OVERLEAF_EMAIL_SMTP_LOGGER: true
OVERLEAF_CUSTOM_EMAIL_FOOTER: ${OVERLEAF_CUSTOM_EMAIL_FOOTER}
#########################################
## Overleaf Filestore S3 Configuration ##
#########################################
OVERLEAF_FILESTORE_BACKEND: s3
OVERLEAF_FILESTORE_USER_FILES_BUCKET_NAME: overleaf-user-files
OVERLEAF_FILESTORE_TEMPLATE_FILES_BUCKET_NAME: overleaf-template-files
OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
OVERLEAF_FILESTORE_S3_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
OVERLEAF_FILESTORE_S3_ENDPOINT: http://s3:8333
OVERLEAF_FILESTORE_S3_PATH_STYLE: "true"
OVERLEAF_FILESTORE_S3_REGION: "us-east-1"
#########################################
## Overleaf History S3 Configuration ##
#########################################
OVERLEAF_HISTORY_BACKEND: s3
OVERLEAF_HISTORY_PROJECT_BLOBS_BUCKET: overleaf-project-blobs
OVERLEAF_HISTORY_CHUNKS_BUCKET: overleaf-chunks
OVERLEAF_HISTORY_S3_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
OVERLEAF_HISTORY_S3_ENDPOINT: http://s3:8333
OVERLEAF_HISTORY_S3_PATH_STYLE: "true"
OVERLEAF_HISTORY_S3_REGION: "us-east-1"
########################
## OIDC ##
########################
EXTERNAL_AUTH: oidc
OVERLEAF_OIDC_PROVIDER_ID: oidc
OVERLEAF_OIDC_ISSUER: ${OVERLEAF_OIDC_ISSUER}
OVERLEAF_OIDC_AUTHORIZATION_URL: ${OVERLEAF_OIDC_ISSUER}/protocol/openid-connect/auth
OVERLEAF_OIDC_TOKEN_URL: ${OVERLEAF_OIDC_ISSUER}/protocol/openid-connect/token
OVERLEAF_OIDC_USER_INFO_URL: ${OVERLEAF_OIDC_ISSUER}/protocol/openid-connect/userinfo
OVERLEAF_OIDC_LOGOUT_URL: ${OVERLEAF_OIDC_ISSUER}/protocol/openid-connect/logout
OVERLEAF_OIDC_IDENTITY_SERVICE_NAME: ${OVERLEAF_OIDC_IDENTITY_SERVICE_NAME}
OVERLEAF_OIDC_CLIENT_ID: ${OVERLEAF_OIDC_CLIENT_ID}
OVERLEAF_OIDC_CLIENT_SECRET: ${OVERLEAF_OIDC_CLIENT_SECRET}
OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN: true
OVERLEAF_OIDC_IS_ADMIN_FIELD: email
OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE: ${OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE}
OVERLEAF_LEFT_FOOTER: ${OVERLEAF_LEFT_FOOTER}
OVERLEAF_RIGHT_FOOTER: '[{"text":"Powered by Overleaf CEP", "url":"https://github.com/yu-i-i/overleaf-cep"}]'
########################
## Sandboxed Compiles ##
########################
SERVER_PRO: true
DOCKER_RUNNER: true
SIBLING_CONTAINERS_ENABLED: true
DOCKER_SOCKET_PATH: /var/run/docker.sock
SANDBOXED_COMPILES: true
SANDBOXED_COMPILES_SIBLING_CONTAINERS: true
# WARNING: CHANGE DIR!!!!
SANDBOXED_COMPILES_HOST_DIR: ${COMPILES_HOST_DIR}
COMPILES_HOST_DIR: ${COMPILES_HOST_DIR}
# png2pdf: CLSI's per-project cache dir must be host-visible so the
# png2pdf sibling container can bind-mount it (CLSI_CACHE_PATH inside
# the container == SANDBOXED_COMPILES_HOST_DIR_CACHE on the host,
# via the ./data -> /var/lib/overleaf mount)
CLSI_CACHE_PATH: /var/lib/overleaf/data/cache
SANDBOXED_COMPILES_HOST_DIR_CACHE: ${COMPILES_HOST_DIR_CACHE}
PNG2PDF_MIN_FILE_SIZE_BYTES: 50000
# filestore PDF→image conversion backend (template gallery preview/thumb).
# Choices:
# imagemagick local `convert` (image must ship ImageMagick)
# pdftocairo local `pdftocairo` (image must ship poppler)
# pdftocairo-sandbox sibling container (no network, CapDrop ALL, mem cap) —
# works on images WITHOUT any converter binaries
CONVERTER: pdftocairo-sandbox
CONVERSION_SANDBOX_IMAGE: sharelatex/pdftocairo:24.02
# Scratch dir bound into the sandbox container — the same directory seen from
# two sides (the overleaf container mounts ./data):
CONVERSION_SANDBOX_DIR: /var/lib/overleaf/tmp/conversions
CONVERSION_SANDBOX_HOST_DIR: ${CONVERSION_SANDBOX_HOST_DIR}
ALL_TEX_LIVE_DOCKER_IMAGES: ${TEX_LIVE_REPO}/texlive-full:2026.1, ${TEX_LIVE_REPO}/texlive-full:2025.1, ${TEX_LIVE_REPO}/texlive-full:2024.1, ${TEX_LIVE_REPO}/texlive-full:2023.1
ALL_TEX_LIVE_DOCKER_IMAGE_NAMES: TeXLive 2026, TeXLive 2025, TeXLive 2024, TeXLive 2023
TEX_LIVE_DOCKER_IMAGE: ${TEX_LIVE_REPO}/texlive-full:2026.1
TEX_COMPILER_EXTRA_FLAGS: -shell-escape
TEXLIVE_IMAGE_USER: www-data
COMPILE_TIMEOUT: 599
#################
# TEMPLATES #
#################
OVERLEAF_TEMPLATE_GALLERY: true
OVERLEAF_NON_ADMIN_CAN_PUBLISH_TEMPLATES: false
OVERLEAF_TEMPLATE_CATEGORIES: presentation thesis
TEMPLATE_PRESENTATION_NAME: Presentations
TEMPLATE_PRESENTATION_DESCRIPTION: Templates for Beamer and other presentation formats, tailored for academic talks and lectures.
TEMPLATE_THESIS_NAME: Theses
TEMPLATE_THESIS_DESCRIPTION: Templates for writing theses and dissertations, following institutional formatting and citation guidelines.
TEMPLATE_ALL_NAME: All templates
TEMPLATE_ALL_DESCRIPTION: Browse a collection of all available LaTeX templates, categorized by document type, style, and purpose.
####################
# Special settings #
####################
COMPILE_BODY_SIZE_LIMIT_MB: 50
COMPILE_SIZE_LIMIT: 50mb
ENABLE_PANDOC_CONVERSIONS: true
PANDOC_IMAGE: sharelatex/sharelatex-pandoc:${OVERLEAF_VERSION}
ENABLE_PNG2PDF_CONVERSIONS: true
PNG2PDF_IMAGE: sharelatex/png2pdf:2026-06-24
ZOTERO_CLIENT_KEY: ${ZOTERO_CLIENT_KEY}
ZOTERO_CLIENT_SECRET: ${ZOTERO_CLIENT_SECRET}
GITHUB_SYNC_ENABLED: true
GITHUB_SYNC_CLIENT_ID: ${GITHUB_SYNC_CLIENT_ID}
GITHUB_SYNC_CLIENT_SECRET: ${GITHUB_SYNC_CLIENT_SECRET}
OVERLEAF_INVITE_TOKEN_SECRET: ${OVERLEAF_INVITE_TOKEN_SECRET}
networks:
- overleaf-network
healthcheck:
test: bash -c "curl -fsI --connect-timeout 10 http://localhost || exit 1"
interval: 30s
timeout: 10s
retries: 5
start_period: 600s
networks:
overleaf-network:
external: true
@@ -0,0 +1,2 @@
data
backup
+1
View File
@@ -0,0 +1 @@
docker exec redis sh -c "redis-cli save && cp -f /data/dump.rdb /backup"
@@ -0,0 +1,30 @@
services:
redis:
image: redis:${REDIS_VERSION}
container_name: redis
hostname: redis
restart: always
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
start_period: 20s
interval: 30s
retries: 5
timeout: 3s
command: --save 60 1 --loglevel warning
volumes:
- ./data:/data
- ./backup:/backup
networks:
- overleaf-network
environment:
REDIS_AOF_PERSISTENCE: "true"
networks:
overleaf-network:
external: true
@@ -0,0 +1 @@
data
@@ -0,0 +1,27 @@
services:
seaweedfs-filer:
image: chrislusf/seaweedfs:latest
container_name: seaweedfs-filer
hostname: seaweedfs-filer
ports:
- "127.0.0.1:8888:8888" # filer HTTP
- "127.0.0.1:18888:18888" # filer gRPC
command: 'filer -master="master:9333" -ip=filer -ip.bind=0.0.0.0'
volumes:
- ./data:/data
networks:
overleaf-network:
aliases:
- filer
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8888/"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
networks:
overleaf-network:
external: true
@@ -0,0 +1 @@
data
@@ -0,0 +1,29 @@
services:
seaweedfs-master:
image: chrislusf/seaweedfs:latest
container_name: seaweedfs-master
hostname: seaweedfs-master
ports:
- "127.0.0.1:9333:9333" # master HTTP
- "127.0.0.1:19333:19333" # master gRPC
command: "master -ip=master -ip.bind=0.0.0.0 -metricsPort=9327"
volumes:
- ./data:/data
networks:
overleaf-network:
# classic seaweedfs names (master:9333 etc.) must resolve on the shared
# network while the container keeps its seaweedfs-* name.
aliases:
- master
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9333/cluster/healthz"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
networks:
overleaf-network:
external: true

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