diff --git a/services/web/app/src/infrastructure/ExpressLocals.mjs b/services/web/app/src/infrastructure/ExpressLocals.mjs index 0418f151e6..d4c70264be 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.mjs +++ b/services/web/app/src/infrastructure/ExpressLocals.mjs @@ -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: diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index e555b8aae4..c561de3633 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -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: { diff --git a/services/web/frontend/js/features/integrations-panel/integrations-panel.tsx b/services/web/frontend/js/features/integrations-panel/integrations-panel.tsx index c931dd79b8..67a3fe29b9 100644 --- a/services/web/frontend/js/features/integrations-panel/integrations-panel.tsx +++ b/services/web/frontend/js/features/integrations-panel/integrations-panel.tsx @@ -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() { diff --git a/services/web/frontend/js/features/settings/components/linking-section.tsx b/services/web/frontend/js/features/settings/components/linking-section.tsx index 1f2e7f6097..79334c6611 100644 --- a/services/web/frontend/js/features/settings/components/linking-section.tsx +++ b/services/web/frontend/js/features/settings/components/linking-section.tsx @@ -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 diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index e033def07d..ae5794ae88 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -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' } diff --git a/services/web/frontend/js/shared/svgs/wakatime-logo.tsx b/services/web/frontend/js/shared/svgs/wakatime-logo.tsx new file mode 100644 index 0000000000..6ee8a195ed --- /dev/null +++ b/services/web/frontend/js/shared/svgs/wakatime-logo.tsx @@ -0,0 +1,27 @@ +function WakaTimeLogo({ size = 40 }: { size?: number }) { + return ( + + + + + ) +} + +export default WakaTimeLogo diff --git a/services/web/modules/wakatime/app/models/wakaTimeUserCredentials.mjs b/services/web/modules/wakatime/app/models/wakaTimeUserCredentials.mjs new file mode 100644 index 0000000000..ddef8d2185 --- /dev/null +++ b/services/web/modules/wakatime/app/models/wakaTimeUserCredentials.mjs @@ -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, +) diff --git a/services/web/modules/wakatime/app/src/AccessTokenEncryptorHelper.mjs b/services/web/modules/wakatime/app/src/AccessTokenEncryptorHelper.mjs new file mode 100644 index 0000000000..059c4cfff2 --- /dev/null +++ b/services/web/modules/wakatime/app/src/AccessTokenEncryptorHelper.mjs @@ -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) + }, +} diff --git a/services/web/modules/wakatime/app/src/TokenManager.mjs b/services/web/modules/wakatime/app/src/TokenManager.mjs new file mode 100644 index 0000000000..7bdc074645 --- /dev/null +++ b/services/web/modules/wakatime/app/src/TokenManager.mjs @@ -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, +} diff --git a/services/web/modules/wakatime/app/src/WakaTimeApiClient.mjs b/services/web/modules/wakatime/app/src/WakaTimeApiClient.mjs new file mode 100644 index 0000000000..373dbfb420 --- /dev/null +++ b/services/web/modules/wakatime/app/src/WakaTimeApiClient.mjs @@ -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, +} diff --git a/services/web/modules/wakatime/app/src/WakaTimeController.mjs b/services/web/modules/wakatime/app/src/WakaTimeController.mjs new file mode 100644 index 0000000000..5ef178caeb --- /dev/null +++ b/services/web/modules/wakatime/app/src/WakaTimeController.mjs @@ -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, +} diff --git a/services/web/modules/wakatime/app/src/WakaTimeRouter.mjs b/services/web/modules/wakatime/app/src/WakaTimeRouter.mjs new file mode 100644 index 0000000000..9318c1e3e8 --- /dev/null +++ b/services/web/modules/wakatime/app/src/WakaTimeRouter.mjs @@ -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 + ) + }, +} diff --git a/services/web/modules/wakatime/frontend/js/components/wakatime-connect-modal.tsx b/services/web/modules/wakatime/frontend/js/components/wakatime-connect-modal.tsx new file mode 100644 index 0000000000..0c329052fa --- /dev/null +++ b/services/web/modules/wakatime/frontend/js/components/wakatime-connect-modal.tsx @@ -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(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 ( + +
+ + {t('connect_wakatime', { defaultValue: 'Connect WakaTime' })} + + + +

+ {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.', + })} +

+ + + {t('api_url', { defaultValue: 'API URL' })} + setApiUrl(e.target.value)} + /> + + + + {t('api_key', { defaultValue: 'API Key' })} + setApiKey(e.target.value)} + /> + + + {error && } +
+ + + + {t('cancel')} + + + {isSaving ? t('saving') : t('connect', { defaultValue: 'Connect' })} + + +
+
+ ) +} diff --git a/services/web/modules/wakatime/frontend/js/components/wakatime-integration-card.tsx b/services/web/modules/wakatime/frontend/js/components/wakatime-integration-card.tsx new file mode 100644 index 0000000000..2cb522b0d5 --- /dev/null +++ b/services/web/modules/wakatime/frontend/js/components/wakatime-integration-card.tsx @@ -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(null) + const [showConnectModal, setShowConnectModal] = useState(false) + + const loadSummary = () => { + if (!projectId) return + getJSON(`/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 ( + <> + } + showPaywallBadge={false} + onClick={ + isConnected + ? undefined + : () => setShowConnectModal(true) + } + href={ + isConnected + ? '/user/settings#project-sync' + : undefined + } + /> + + setShowConnectModal(false)} + onLinked={loadSummary} + /> + + ) +} + +export default WakaTimeCard diff --git a/services/web/modules/wakatime/frontend/js/components/wakatime-widget.tsx b/services/web/modules/wakatime/frontend/js/components/wakatime-widget.tsx new file mode 100644 index 0000000000..0a3ab15415 --- /dev/null +++ b/services/web/modules/wakatime/frontend/js/components/wakatime-widget.tsx @@ -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() + + const { + isLoading: isUnlinking, + isError: isErrorUnlink, + runAsync: runAsyncUnlink, + } = useAsync() + + 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 ( +
+
+ +
+
+
+

WakaTime

+
+

+ {t('loading')}… +

+
+
+ ) + } + + const isConnected = !!status?.connected + + return ( + <> +
+
+ +
+ +
+
+ +
+ +

+ {t('wakatime_widget_description', { + defaultValue: + 'Track coding time in your projects with WakaTime or a self-hosted Wakapi server.', + })} +

+ + {isConnected && status?.apiUrl && ( +

{status.apiUrl}

+ )} + + {isErrorConnCheck && ( + + )} + + {isErrorUnlink && ( + + )} +
+ +
+ {isConnected ? ( + + {isUnlinking ? t('unlinking') : t('unlink')} + + ) : ( + setShowConnectModal(true)}> + {t('link')} + + )} +
+
+ + setShowConnectModal(false)} + onLinked={handleConnCheck} + /> + + ) +} diff --git a/services/web/modules/wakatime/frontend/js/editor/wakatime-source-editor-extension.ts b/services/web/modules/wakatime/frontend/js/editor/wakatime-source-editor-extension.ts new file mode 100644 index 0000000000..be0870c1f7 --- /dev/null +++ b/services/web/modules/wakatime/frontend/js/editor/wakatime-source-editor-extension.ts @@ -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) => { + 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) + }, + })) +} diff --git a/services/web/modules/wakatime/frontend/js/editor/wakatime-tracker.ts b/services/web/modules/wakatime/frontend/js/editor/wakatime-tracker.ts new file mode 100644 index 0000000000..58dd908de6 --- /dev/null +++ b/services/web/modules/wakatime/frontend/js/editor/wakatime-tracker.ts @@ -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 = { + 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() + +function buildHeartbeat(docName: string, isWrite: boolean) { + const heartbeat: Record = { + 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) +} diff --git a/services/web/modules/wakatime/index.mjs b/services/web/modules/wakatime/index.mjs new file mode 100644 index 0000000000..7ddcb24f21 --- /dev/null +++ b/services/web/modules/wakatime/index.mjs @@ -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 diff --git a/services/web/types/exposed-settings.ts b/services/web/types/exposed-settings.ts index 11bf543b6b..c51711b3ec 100644 --- a/services/web/types/exposed-settings.ts +++ b/services/web/types/exposed-settings.ts @@ -56,6 +56,7 @@ export type ExposedSettings = { linkedInInsightsPartnerId?: string githubSyncEnabled: boolean zoteroEnabled: boolean + wakaTimeEnabled: boolean languageToolAvailable: boolean enablePandocConversions: boolean } diff --git a/tools/reference_stack_compose/.gitignore b/tools/reference_stack_compose/.gitignore new file mode 100644 index 0000000000..8555ffe5a1 --- /dev/null +++ b/tools/reference_stack_compose/.gitignore @@ -0,0 +1,4 @@ +.env +backup_seaweedfs_online + +workbench diff --git a/tools/reference_stack_compose/0_create_network.sh b/tools/reference_stack_compose/0_create_network.sh new file mode 100644 index 0000000000..bdf6abc8cc --- /dev/null +++ b/tools/reference_stack_compose/0_create_network.sh @@ -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 + diff --git a/tools/reference_stack_compose/0_create_s3_buckets.sh b/tools/reference_stack_compose/0_create_s3_buckets.sh new file mode 100644 index 0000000000..8a9266d68d --- /dev/null +++ b/tools/reference_stack_compose/0_create_s3_buckets.sh @@ -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 diff --git a/tools/reference_stack_compose/0_ensure_local_auth_type.sh b/tools/reference_stack_compose/0_ensure_local_auth_type.sh new file mode 100755 index 0000000000..acde1e6191 --- /dev/null +++ b/tools/reference_stack_compose/0_ensure_local_auth_type.sh @@ -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 diff --git a/tools/reference_stack_compose/0_install_aws.sh b/tools/reference_stack_compose/0_install_aws.sh new file mode 100644 index 0000000000..a586829c65 --- /dev/null +++ b/tools/reference_stack_compose/0_install_aws.sh @@ -0,0 +1,2 @@ +sudo apt update +sudo apt install -y awscli diff --git a/tools/reference_stack_compose/0_online_backup_s3.sh b/tools/reference_stack_compose/0_online_backup_s3.sh new file mode 100755 index 0000000000..24d0a4b458 --- /dev/null +++ b/tools/reference_stack_compose/0_online_backup_s3.sh @@ -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" + diff --git a/tools/reference_stack_compose/0_pull_other_containers.sh b/tools/reference_stack_compose/0_pull_other_containers.sh new file mode 100755 index 0000000000..6f2c12c771 --- /dev/null +++ b/tools/reference_stack_compose/0_pull_other_containers.sh @@ -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 diff --git a/tools/reference_stack_compose/README.md b/tools/reference_stack_compose/README.md new file mode 100644 index 0000000000..748c47dfb8 --- /dev/null +++ b/tools/reference_stack_compose/README.md @@ -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 +/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) +/data* # live data volumes (mongo, redis, seaweedfs-*, overleaf, ...) — git-ignored +/backup/ # backup dumps (git-ignored) +backup_seaweedfs_online/# timestamped SeaweedFS online backups (git-ignored) +/backup.sh # mongo/, wakapi-pg/: dump into /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 ``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_.sh # start/recreate one service (mongo, redis, overleaf, nginx, + # wakapi, wakapi-pg, wakapi-probe, seaweedfs-*, git-bridge, languagetool) +bash down_.sh # stop+remove it +bash logs_.sh # follow logs +bash exec_.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_.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 "/" 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=`, + then `docker rm ` (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` diff --git a/tools/reference_stack_compose/autoupdate_languagetool.sh b/tools/reference_stack_compose/autoupdate_languagetool.sh new file mode 100755 index 0000000000..68e28da928 --- /dev/null +++ b/tools/reference_stack_compose/autoupdate_languagetool.sh @@ -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 diff --git a/tools/reference_stack_compose/autoupdate_mongo.sh b/tools/reference_stack_compose/autoupdate_mongo.sh new file mode 100755 index 0000000000..d07c9aaf1d --- /dev/null +++ b/tools/reference_stack_compose/autoupdate_mongo.sh @@ -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 diff --git a/tools/reference_stack_compose/autoupdate_nginx.sh b/tools/reference_stack_compose/autoupdate_nginx.sh new file mode 100755 index 0000000000..3a329cdc9e --- /dev/null +++ b/tools/reference_stack_compose/autoupdate_nginx.sh @@ -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 diff --git a/tools/reference_stack_compose/autoupdate_redis.sh b/tools/reference_stack_compose/autoupdate_redis.sh new file mode 100755 index 0000000000..f33f717478 --- /dev/null +++ b/tools/reference_stack_compose/autoupdate_redis.sh @@ -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 diff --git a/tools/reference_stack_compose/autoupdate_seaweedfs-filer.sh b/tools/reference_stack_compose/autoupdate_seaweedfs-filer.sh new file mode 100755 index 0000000000..a5396a9b25 --- /dev/null +++ b/tools/reference_stack_compose/autoupdate_seaweedfs-filer.sh @@ -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 diff --git a/tools/reference_stack_compose/autoupdate_seaweedfs-master.sh b/tools/reference_stack_compose/autoupdate_seaweedfs-master.sh new file mode 100755 index 0000000000..8e92f56161 --- /dev/null +++ b/tools/reference_stack_compose/autoupdate_seaweedfs-master.sh @@ -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 diff --git a/tools/reference_stack_compose/autoupdate_seaweedfs-s3.sh b/tools/reference_stack_compose/autoupdate_seaweedfs-s3.sh new file mode 100755 index 0000000000..198a5d2eba --- /dev/null +++ b/tools/reference_stack_compose/autoupdate_seaweedfs-s3.sh @@ -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 diff --git a/tools/reference_stack_compose/autoupdate_seaweedfs-volume.sh b/tools/reference_stack_compose/autoupdate_seaweedfs-volume.sh new file mode 100755 index 0000000000..61e7f975c0 --- /dev/null +++ b/tools/reference_stack_compose/autoupdate_seaweedfs-volume.sh @@ -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 diff --git a/tools/reference_stack_compose/compose.yaml b/tools/reference_stack_compose/compose.yaml new file mode 100644 index 0000000000..ee88d5cd4e --- /dev/null +++ b/tools/reference_stack_compose/compose.yaml @@ -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 diff --git a/tools/reference_stack_compose/cycle_nginx.sh b/tools/reference_stack_compose/cycle_nginx.sh new file mode 100755 index 0000000000..8b574463e9 --- /dev/null +++ b/tools/reference_stack_compose/cycle_nginx.sh @@ -0,0 +1,3 @@ +docker compose down nginx +docker compose up -d nginx + diff --git a/tools/reference_stack_compose/cycle_overleaf.sh b/tools/reference_stack_compose/cycle_overleaf.sh new file mode 100755 index 0000000000..15d9309076 --- /dev/null +++ b/tools/reference_stack_compose/cycle_overleaf.sh @@ -0,0 +1,3 @@ +docker compose down overleaf +docker compose up -d overleaf + diff --git a/tools/reference_stack_compose/down.sh b/tools/reference_stack_compose/down.sh new file mode 100644 index 0000000000..c8642093b6 --- /dev/null +++ b/tools/reference_stack_compose/down.sh @@ -0,0 +1,2 @@ +docker compose down + diff --git a/tools/reference_stack_compose/down_git-bridge.sh b/tools/reference_stack_compose/down_git-bridge.sh new file mode 100644 index 0000000000..287bc0d9f0 --- /dev/null +++ b/tools/reference_stack_compose/down_git-bridge.sh @@ -0,0 +1,2 @@ +docker compose down git-bridge + diff --git a/tools/reference_stack_compose/down_languagetool.sh b/tools/reference_stack_compose/down_languagetool.sh new file mode 100644 index 0000000000..46f3c82a63 --- /dev/null +++ b/tools/reference_stack_compose/down_languagetool.sh @@ -0,0 +1,3 @@ +docker compose down languagetool + + diff --git a/tools/reference_stack_compose/down_mongo.sh b/tools/reference_stack_compose/down_mongo.sh new file mode 100644 index 0000000000..2ca911e893 --- /dev/null +++ b/tools/reference_stack_compose/down_mongo.sh @@ -0,0 +1,2 @@ +docker compose down mongo + diff --git a/tools/reference_stack_compose/down_nginx.sh b/tools/reference_stack_compose/down_nginx.sh new file mode 100644 index 0000000000..7af92fe52c --- /dev/null +++ b/tools/reference_stack_compose/down_nginx.sh @@ -0,0 +1,2 @@ +docker compose down nginx + diff --git a/tools/reference_stack_compose/down_overleaf.sh b/tools/reference_stack_compose/down_overleaf.sh new file mode 100644 index 0000000000..910e335438 --- /dev/null +++ b/tools/reference_stack_compose/down_overleaf.sh @@ -0,0 +1,2 @@ +docker compose down overleaf + diff --git a/tools/reference_stack_compose/down_redis.sh b/tools/reference_stack_compose/down_redis.sh new file mode 100644 index 0000000000..383967f5f0 --- /dev/null +++ b/tools/reference_stack_compose/down_redis.sh @@ -0,0 +1,2 @@ +docker compose down redis + diff --git a/tools/reference_stack_compose/down_seaweedfs-filer.sh b/tools/reference_stack_compose/down_seaweedfs-filer.sh new file mode 100644 index 0000000000..54d886b00e --- /dev/null +++ b/tools/reference_stack_compose/down_seaweedfs-filer.sh @@ -0,0 +1,2 @@ +docker compose down seaweedfs-filer + diff --git a/tools/reference_stack_compose/down_seaweedfs-master.sh b/tools/reference_stack_compose/down_seaweedfs-master.sh new file mode 100644 index 0000000000..b57048304a --- /dev/null +++ b/tools/reference_stack_compose/down_seaweedfs-master.sh @@ -0,0 +1,2 @@ +docker compose down seaweedfs-master + diff --git a/tools/reference_stack_compose/down_seaweedfs-s3.sh b/tools/reference_stack_compose/down_seaweedfs-s3.sh new file mode 100644 index 0000000000..557430e817 --- /dev/null +++ b/tools/reference_stack_compose/down_seaweedfs-s3.sh @@ -0,0 +1,2 @@ +docker compose down seaweedfs-s3 + diff --git a/tools/reference_stack_compose/down_seaweedfs-volume.sh b/tools/reference_stack_compose/down_seaweedfs-volume.sh new file mode 100644 index 0000000000..f21d3d8b27 --- /dev/null +++ b/tools/reference_stack_compose/down_seaweedfs-volume.sh @@ -0,0 +1,2 @@ +docker compose down seaweedfs-volume + diff --git a/tools/reference_stack_compose/down_wakapi-pg.sh b/tools/reference_stack_compose/down_wakapi-pg.sh new file mode 100644 index 0000000000..e58104da16 --- /dev/null +++ b/tools/reference_stack_compose/down_wakapi-pg.sh @@ -0,0 +1,2 @@ +docker compose down wakapi-pg + diff --git a/tools/reference_stack_compose/down_wakapi-probe.sh b/tools/reference_stack_compose/down_wakapi-probe.sh new file mode 100644 index 0000000000..99d478a6b4 --- /dev/null +++ b/tools/reference_stack_compose/down_wakapi-probe.sh @@ -0,0 +1,2 @@ +docker compose down wakapi-probe + diff --git a/tools/reference_stack_compose/down_wakapi.sh b/tools/reference_stack_compose/down_wakapi.sh new file mode 100644 index 0000000000..c380575235 --- /dev/null +++ b/tools/reference_stack_compose/down_wakapi.sh @@ -0,0 +1,2 @@ +docker compose down wakapi + diff --git a/tools/reference_stack_compose/env.sample b/tools/reference_stack_compose/env.sample new file mode 100644 index 0000000000..dab07737f8 --- /dev/null +++ b/tools/reference_stack_compose/env.sample @@ -0,0 +1,105 @@ +# ============================================================================ +# env.sample — template for /data_1/docker/compose/.env +# +# HOW TO USE: cp env.sample .env then fill in the 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="
University of Bremen Overleaf Wiki " + + +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= +# openssl rand -base64 60 | tr -dc 'a-zA-Z0-9' | head -c 40; echo +AWS_SECRET_ACCESS_KEY= + +# Zotero +# (credentials from your Zotero account: Settings -> Advanced -> Credentials) +ZOTERO_CLIENT_KEY= +ZOTERO_CLIENT_SECRET= + +# Github +# (from your GitHub App settings) +GITHUB_SYNC_CLIENT_ID= +GITHUB_SYNC_CLIENT_SECRET= + +# openssl rand -hex 32; echo +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_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_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= + +# 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 + diff --git a/tools/reference_stack_compose/exec_git-bridge.sh b/tools/reference_stack_compose/exec_git-bridge.sh new file mode 100755 index 0000000000..7f7185cf13 --- /dev/null +++ b/tools/reference_stack_compose/exec_git-bridge.sh @@ -0,0 +1 @@ +docker exec -it git-bridge sh diff --git a/tools/reference_stack_compose/exec_mongo.sh b/tools/reference_stack_compose/exec_mongo.sh new file mode 100755 index 0000000000..2780ce2bcf --- /dev/null +++ b/tools/reference_stack_compose/exec_mongo.sh @@ -0,0 +1 @@ +docker exec -it mongo sh diff --git a/tools/reference_stack_compose/exec_nginx.sh b/tools/reference_stack_compose/exec_nginx.sh new file mode 100755 index 0000000000..7601b2d116 --- /dev/null +++ b/tools/reference_stack_compose/exec_nginx.sh @@ -0,0 +1 @@ +docker exec -it nginx sh diff --git a/tools/reference_stack_compose/exec_overleaf.sh b/tools/reference_stack_compose/exec_overleaf.sh new file mode 100755 index 0000000000..3ea27d2b3b --- /dev/null +++ b/tools/reference_stack_compose/exec_overleaf.sh @@ -0,0 +1 @@ +docker exec -it overleaf sh diff --git a/tools/reference_stack_compose/exec_redis.sh b/tools/reference_stack_compose/exec_redis.sh new file mode 100755 index 0000000000..44c1f2939c --- /dev/null +++ b/tools/reference_stack_compose/exec_redis.sh @@ -0,0 +1 @@ +docker exec -it redis sh diff --git a/tools/reference_stack_compose/exec_seaweedfs-filer.sh b/tools/reference_stack_compose/exec_seaweedfs-filer.sh new file mode 100755 index 0000000000..f6cb294e9b --- /dev/null +++ b/tools/reference_stack_compose/exec_seaweedfs-filer.sh @@ -0,0 +1 @@ +docker exec -it seaweedfs-filer sh diff --git a/tools/reference_stack_compose/exec_seaweedfs-master.sh b/tools/reference_stack_compose/exec_seaweedfs-master.sh new file mode 100755 index 0000000000..bd3e3ae7d2 --- /dev/null +++ b/tools/reference_stack_compose/exec_seaweedfs-master.sh @@ -0,0 +1 @@ +docker exec -it seaweedfs-master sh diff --git a/tools/reference_stack_compose/exec_seaweedfs-s3.sh b/tools/reference_stack_compose/exec_seaweedfs-s3.sh new file mode 100755 index 0000000000..ba47aa5117 --- /dev/null +++ b/tools/reference_stack_compose/exec_seaweedfs-s3.sh @@ -0,0 +1 @@ +docker exec -it seaweedfs-s3 sh diff --git a/tools/reference_stack_compose/exec_seaweedfs-volume.sh b/tools/reference_stack_compose/exec_seaweedfs-volume.sh new file mode 100755 index 0000000000..55c773d996 --- /dev/null +++ b/tools/reference_stack_compose/exec_seaweedfs-volume.sh @@ -0,0 +1 @@ +docker exec -it seaweedfs-volume sh diff --git a/tools/reference_stack_compose/exec_wakapi-pg.sh b/tools/reference_stack_compose/exec_wakapi-pg.sh new file mode 100755 index 0000000000..f4375dfcba --- /dev/null +++ b/tools/reference_stack_compose/exec_wakapi-pg.sh @@ -0,0 +1 @@ +docker exec -it wakapi-pg sh diff --git a/tools/reference_stack_compose/exec_wakapi-probe.sh b/tools/reference_stack_compose/exec_wakapi-probe.sh new file mode 100755 index 0000000000..c3531f326a --- /dev/null +++ b/tools/reference_stack_compose/exec_wakapi-probe.sh @@ -0,0 +1 @@ +docker exec -it wakapi-probe sh diff --git a/tools/reference_stack_compose/exec_wakapi.sh b/tools/reference_stack_compose/exec_wakapi.sh new file mode 100755 index 0000000000..cf6b11c03f --- /dev/null +++ b/tools/reference_stack_compose/exec_wakapi.sh @@ -0,0 +1 @@ +docker exec -it wakapi sh diff --git a/tools/reference_stack_compose/git-bridge/.gitignore b/tools/reference_stack_compose/git-bridge/.gitignore new file mode 100644 index 0000000000..6320cd248d --- /dev/null +++ b/tools/reference_stack_compose/git-bridge/.gitignore @@ -0,0 +1 @@ +data \ No newline at end of file diff --git a/tools/reference_stack_compose/git-bridge/compose.yaml b/tools/reference_stack_compose/git-bridge/compose.yaml new file mode 100644 index 0000000000..0fb9d75326 --- /dev/null +++ b/tools/reference_stack_compose/git-bridge/compose.yaml @@ -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 + diff --git a/tools/reference_stack_compose/languagetool/.gitignore b/tools/reference_stack_compose/languagetool/.gitignore new file mode 100644 index 0000000000..32b1038359 --- /dev/null +++ b/tools/reference_stack_compose/languagetool/.gitignore @@ -0,0 +1 @@ +ngrams diff --git a/tools/reference_stack_compose/languagetool/compose.yaml b/tools/reference_stack_compose/languagetool/compose.yaml new file mode 100644 index 0000000000..b74343420d --- /dev/null +++ b/tools/reference_stack_compose/languagetool/compose.yaml @@ -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 diff --git a/tools/reference_stack_compose/languagetool/download_ngrams.sh b/tools/reference_stack_compose/languagetool/download_ngrams.sh new file mode 100755 index 0000000000..00cfce3a5b --- /dev/null +++ b/tools/reference_stack_compose/languagetool/download_ngrams.sh @@ -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" diff --git a/tools/reference_stack_compose/languagetool/download_ngrams_untested.sh b/tools/reference_stack_compose/languagetool/download_ngrams_untested.sh new file mode 100755 index 0000000000..9aef2271db --- /dev/null +++ b/tools/reference_stack_compose/languagetool/download_ngrams_untested.sh @@ -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" diff --git a/tools/reference_stack_compose/languagetool/lt-test.tex b/tools/reference_stack_compose/languagetool/lt-test.tex new file mode 100644 index 0000000000..a63eabb256 --- /dev/null +++ b/tools/reference_stack_compose/languagetool/lt-test.tex @@ -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} diff --git a/tools/reference_stack_compose/logs_git-bridge.sh b/tools/reference_stack_compose/logs_git-bridge.sh new file mode 100755 index 0000000000..c989504d87 --- /dev/null +++ b/tools/reference_stack_compose/logs_git-bridge.sh @@ -0,0 +1,2 @@ +docker compose logs -f git-bridge + diff --git a/tools/reference_stack_compose/logs_languagetool.sh b/tools/reference_stack_compose/logs_languagetool.sh new file mode 100755 index 0000000000..f1b0670c34 --- /dev/null +++ b/tools/reference_stack_compose/logs_languagetool.sh @@ -0,0 +1,2 @@ +docker compose logs -f languagetool + diff --git a/tools/reference_stack_compose/logs_mongo.sh b/tools/reference_stack_compose/logs_mongo.sh new file mode 100755 index 0000000000..d73fed5b67 --- /dev/null +++ b/tools/reference_stack_compose/logs_mongo.sh @@ -0,0 +1,2 @@ +docker compose logs -f mongo + diff --git a/tools/reference_stack_compose/logs_nginx.sh b/tools/reference_stack_compose/logs_nginx.sh new file mode 100755 index 0000000000..e281259afd --- /dev/null +++ b/tools/reference_stack_compose/logs_nginx.sh @@ -0,0 +1,2 @@ +docker compose logs -f nginx + diff --git a/tools/reference_stack_compose/logs_overleaf.sh b/tools/reference_stack_compose/logs_overleaf.sh new file mode 100755 index 0000000000..152b58a87c --- /dev/null +++ b/tools/reference_stack_compose/logs_overleaf.sh @@ -0,0 +1,2 @@ +docker compose logs -f overleaf + diff --git a/tools/reference_stack_compose/logs_redis.sh b/tools/reference_stack_compose/logs_redis.sh new file mode 100755 index 0000000000..587cb0ce56 --- /dev/null +++ b/tools/reference_stack_compose/logs_redis.sh @@ -0,0 +1,2 @@ +docker compose logs -f redis + diff --git a/tools/reference_stack_compose/logs_seaweedfs-filer.sh b/tools/reference_stack_compose/logs_seaweedfs-filer.sh new file mode 100755 index 0000000000..c07b70197f --- /dev/null +++ b/tools/reference_stack_compose/logs_seaweedfs-filer.sh @@ -0,0 +1,2 @@ +docker compose logs -f seaweedfs-filer + diff --git a/tools/reference_stack_compose/logs_seaweedfs-master.sh b/tools/reference_stack_compose/logs_seaweedfs-master.sh new file mode 100755 index 0000000000..92da34358b --- /dev/null +++ b/tools/reference_stack_compose/logs_seaweedfs-master.sh @@ -0,0 +1,2 @@ +docker compose logs -f seaweedfs-master + diff --git a/tools/reference_stack_compose/logs_seaweedfs-s3.sh b/tools/reference_stack_compose/logs_seaweedfs-s3.sh new file mode 100755 index 0000000000..2c04282ef0 --- /dev/null +++ b/tools/reference_stack_compose/logs_seaweedfs-s3.sh @@ -0,0 +1,2 @@ +docker compose logs -f seaweedfs-s3 + diff --git a/tools/reference_stack_compose/logs_seaweedfs-volume.sh b/tools/reference_stack_compose/logs_seaweedfs-volume.sh new file mode 100755 index 0000000000..a1a1a6c610 --- /dev/null +++ b/tools/reference_stack_compose/logs_seaweedfs-volume.sh @@ -0,0 +1,2 @@ +docker compose logs -f seaweedfs-volume + diff --git a/tools/reference_stack_compose/logs_wakapi-pg.sh b/tools/reference_stack_compose/logs_wakapi-pg.sh new file mode 100755 index 0000000000..a7a40ebc6a --- /dev/null +++ b/tools/reference_stack_compose/logs_wakapi-pg.sh @@ -0,0 +1,2 @@ +docker compose logs -f wakapi-pg + diff --git a/tools/reference_stack_compose/logs_wakapi-probe.sh b/tools/reference_stack_compose/logs_wakapi-probe.sh new file mode 100755 index 0000000000..6f09cbb69c --- /dev/null +++ b/tools/reference_stack_compose/logs_wakapi-probe.sh @@ -0,0 +1,2 @@ +docker compose logs -f wakapi-probe + diff --git a/tools/reference_stack_compose/logs_wakapi.sh b/tools/reference_stack_compose/logs_wakapi.sh new file mode 100755 index 0000000000..ae0378442b --- /dev/null +++ b/tools/reference_stack_compose/logs_wakapi.sh @@ -0,0 +1,2 @@ +docker compose logs -f wakapi + diff --git a/tools/reference_stack_compose/mongo/.gitignore b/tools/reference_stack_compose/mongo/.gitignore new file mode 100644 index 0000000000..09e1d8b091 --- /dev/null +++ b/tools/reference_stack_compose/mongo/.gitignore @@ -0,0 +1,2 @@ +backup +data_db diff --git a/tools/reference_stack_compose/mongo/backup.sh b/tools/reference_stack_compose/mongo/backup.sh new file mode 100755 index 0000000000..effcbba213 --- /dev/null +++ b/tools/reference_stack_compose/mongo/backup.sh @@ -0,0 +1 @@ +docker exec mongo bash -c "mongodump --out /backup/" diff --git a/tools/reference_stack_compose/mongo/compose.yaml b/tools/reference_stack_compose/mongo/compose.yaml new file mode 100644 index 0000000000..cc91c714e6 --- /dev/null +++ b/tools/reference_stack_compose/mongo/compose.yaml @@ -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 diff --git a/tools/reference_stack_compose/nginx/compose.yaml b/tools/reference_stack_compose/nginx/compose.yaml new file mode 100644 index 0000000000..5dccb10247 --- /dev/null +++ b/tools/reference_stack_compose/nginx/compose.yaml @@ -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 diff --git a/tools/reference_stack_compose/nginx/nginx.conf b/tools/reference_stack_compose/nginx/nginx.conf new file mode 100644 index 0000000000..abe111edb6 --- /dev/null +++ b/tools/reference_stack_compose/nginx/nginx.conf @@ -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; + } + } +} + diff --git a/tools/reference_stack_compose/nginx/static/ub/logo.svg b/tools/reference_stack_compose/nginx/static/ub/logo.svg new file mode 100644 index 0000000000..8909526322 --- /dev/null +++ b/tools/reference_stack_compose/nginx/static/ub/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tools/reference_stack_compose/overleaf/.gitignore b/tools/reference_stack_compose/overleaf/.gitignore new file mode 100644 index 0000000000..328c8678c1 --- /dev/null +++ b/tools/reference_stack_compose/overleaf/.gitignore @@ -0,0 +1,2 @@ +data +log diff --git a/tools/reference_stack_compose/overleaf/compose.yaml b/tools/reference_stack_compose/overleaf/compose.yaml new file mode 100644 index 0000000000..ce4c55747b --- /dev/null +++ b/tools/reference_stack_compose/overleaf/compose.yaml @@ -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 + diff --git a/tools/reference_stack_compose/redis/.gitignore b/tools/reference_stack_compose/redis/.gitignore new file mode 100644 index 0000000000..e1ce5eb090 --- /dev/null +++ b/tools/reference_stack_compose/redis/.gitignore @@ -0,0 +1,2 @@ +data +backup diff --git a/tools/reference_stack_compose/redis/backup.sh b/tools/reference_stack_compose/redis/backup.sh new file mode 100755 index 0000000000..d843a25cf9 --- /dev/null +++ b/tools/reference_stack_compose/redis/backup.sh @@ -0,0 +1 @@ +docker exec redis sh -c "redis-cli save && cp -f /data/dump.rdb /backup" diff --git a/tools/reference_stack_compose/redis/compose.yaml b/tools/reference_stack_compose/redis/compose.yaml new file mode 100644 index 0000000000..39fd8afa08 --- /dev/null +++ b/tools/reference_stack_compose/redis/compose.yaml @@ -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 + diff --git a/tools/reference_stack_compose/seaweedfs-filer/.gitignore b/tools/reference_stack_compose/seaweedfs-filer/.gitignore new file mode 100644 index 0000000000..6320cd248d --- /dev/null +++ b/tools/reference_stack_compose/seaweedfs-filer/.gitignore @@ -0,0 +1 @@ +data \ No newline at end of file diff --git a/tools/reference_stack_compose/seaweedfs-filer/compose.yaml b/tools/reference_stack_compose/seaweedfs-filer/compose.yaml new file mode 100644 index 0000000000..c743332cd8 --- /dev/null +++ b/tools/reference_stack_compose/seaweedfs-filer/compose.yaml @@ -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 diff --git a/tools/reference_stack_compose/seaweedfs-master/.gitignore b/tools/reference_stack_compose/seaweedfs-master/.gitignore new file mode 100644 index 0000000000..6320cd248d --- /dev/null +++ b/tools/reference_stack_compose/seaweedfs-master/.gitignore @@ -0,0 +1 @@ +data \ No newline at end of file diff --git a/tools/reference_stack_compose/seaweedfs-master/compose.yaml b/tools/reference_stack_compose/seaweedfs-master/compose.yaml new file mode 100644 index 0000000000..db7ca3768c --- /dev/null +++ b/tools/reference_stack_compose/seaweedfs-master/compose.yaml @@ -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 diff --git a/tools/reference_stack_compose/seaweedfs-s3/compose.yaml b/tools/reference_stack_compose/seaweedfs-s3/compose.yaml new file mode 100644 index 0000000000..43b5ac2210 --- /dev/null +++ b/tools/reference_stack_compose/seaweedfs-s3/compose.yaml @@ -0,0 +1,29 @@ +services: + seaweedfs-s3: + image: chrislusf/seaweedfs:latest + container_name: seaweedfs-s3 + hostname: seaweedfs-s3 + ports: + - "127.0.0.1:8333:8333" # S3 API + command: 's3 -filer="filer:8888" -ip.bind=0.0.0.0' + + environment: + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + + networks: + overleaf-network: + aliases: + - s3 + restart: unless-stopped + + healthcheck: + test: ["CMD-SHELL", "wget -S -o- http://localhost:8333/ 2>&1 | grep -q 'HTTP/'"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + +networks: + overleaf-network: + external: true diff --git a/tools/reference_stack_compose/seaweedfs-volume/.gitignore b/tools/reference_stack_compose/seaweedfs-volume/.gitignore new file mode 100644 index 0000000000..6320cd248d --- /dev/null +++ b/tools/reference_stack_compose/seaweedfs-volume/.gitignore @@ -0,0 +1 @@ +data \ No newline at end of file diff --git a/tools/reference_stack_compose/seaweedfs-volume/compose.yaml b/tools/reference_stack_compose/seaweedfs-volume/compose.yaml new file mode 100644 index 0000000000..446bad67c1 --- /dev/null +++ b/tools/reference_stack_compose/seaweedfs-volume/compose.yaml @@ -0,0 +1,27 @@ +services: + seaweedfs-volume: + image: chrislusf/seaweedfs:latest + container_name: seaweedfs-volume + # no host port mapping: volume is only reached inside the network as + # volume:8080 (host 8080 is taken by another service) — gRPC stays mapped. + ports: + - "127.0.0.1:18080:18080" # volume gRPC + command: 'volume -mserver="master:9333" -ip=volume -ip.bind=0.0.0.0 -port=8080 -dir=/data -max=0' + volumes: + - ./data:/data + networks: + overleaf-network: + aliases: + - volume + restart: unless-stopped + + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/status"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + +networks: + overleaf-network: + external: true diff --git a/tools/reference_stack_compose/up.sh b/tools/reference_stack_compose/up.sh new file mode 100644 index 0000000000..533147a09f --- /dev/null +++ b/tools/reference_stack_compose/up.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# up.sh — cold/hot start of the whole stack, wakapi-safe ordering: +# 1) wakapi-pg up, drop the normalization trigger (if any) so wakapi's +# pending startup DDL can never be blocked by it (SQLSTATE 0A000 brick — +# see 0_ensure_local_auth_type.sh header). +# 2) rest of the stack up (overleaf included, so its IP becomes known). +# 3) pin OVERLEAF_IP (was valid only while overleaf was running before). +# 4) recreate wakapi — automatically, only if the pin changed it. +# 5) re-apply trigger + backfill (no-op if the DB is already normalized). +set -euo pipefail +cd "$(dirname "$0")" + +# 1) pg first + trigger removed for the startup window +docker compose up -d wakapi-pg +bash 0_ensure_local_auth_type.sh preflight + +# 2) everything else (.env may carry a stale OVERLEAF_IP; corrected in 3) +docker compose up -d + +# 3) pin OVERLEAF_IP to the now-known overleaf container IP (single-IP +# least-privilege trusted-source model for wakapi) +ip="$(docker inspect -f '{{(index .NetworkSettings.Networks "overleaf-network").IPAddress}}' overleaf)" +grep -q '^OVERLEAF_IP=' .env && sed -i "s/^OVERLEAF_IP=.*/OVERLEAF_IP=$ip/" .env || echo "OVERLEAF_IP=$ip" >> .env +echo "pinned OVERLEAF_IP=$ip" + +# 4) pick up the new pin if it changed (no-op otherwise; heartbeats in the +# 2→4 window may be rejected — fire-and-forget client, self-heals) +docker compose up -d wakapi + +# 5) normalization back on (waits until wakapi serves) +bash 0_ensure_local_auth_type.sh apply diff --git a/tools/reference_stack_compose/up_git-bridge.sh b/tools/reference_stack_compose/up_git-bridge.sh new file mode 100644 index 0000000000..8197da1345 --- /dev/null +++ b/tools/reference_stack_compose/up_git-bridge.sh @@ -0,0 +1,2 @@ +docker compose up -d git-bridge + diff --git a/tools/reference_stack_compose/up_languagetool.sh b/tools/reference_stack_compose/up_languagetool.sh new file mode 100644 index 0000000000..9fbf48ed78 --- /dev/null +++ b/tools/reference_stack_compose/up_languagetool.sh @@ -0,0 +1,2 @@ +docker compose up -d languagetool + diff --git a/tools/reference_stack_compose/up_mongo.sh b/tools/reference_stack_compose/up_mongo.sh new file mode 100644 index 0000000000..30cac805ea --- /dev/null +++ b/tools/reference_stack_compose/up_mongo.sh @@ -0,0 +1,2 @@ +docker compose up -d mongo + diff --git a/tools/reference_stack_compose/up_nginx.sh b/tools/reference_stack_compose/up_nginx.sh new file mode 100644 index 0000000000..14754aed5d --- /dev/null +++ b/tools/reference_stack_compose/up_nginx.sh @@ -0,0 +1,2 @@ +docker compose up -d nginx + diff --git a/tools/reference_stack_compose/up_overleaf.sh b/tools/reference_stack_compose/up_overleaf.sh new file mode 100644 index 0000000000..dde711bab7 --- /dev/null +++ b/tools/reference_stack_compose/up_overleaf.sh @@ -0,0 +1,2 @@ +docker compose up -d overleaf + diff --git a/tools/reference_stack_compose/up_redis.sh b/tools/reference_stack_compose/up_redis.sh new file mode 100644 index 0000000000..61e9edf6cd --- /dev/null +++ b/tools/reference_stack_compose/up_redis.sh @@ -0,0 +1,2 @@ +docker compose up -d redis + diff --git a/tools/reference_stack_compose/up_seaweedfs-filer.sh b/tools/reference_stack_compose/up_seaweedfs-filer.sh new file mode 100644 index 0000000000..bdd33090b3 --- /dev/null +++ b/tools/reference_stack_compose/up_seaweedfs-filer.sh @@ -0,0 +1,2 @@ +docker compose up -d seaweedfs-filer + diff --git a/tools/reference_stack_compose/up_seaweedfs-master.sh b/tools/reference_stack_compose/up_seaweedfs-master.sh new file mode 100644 index 0000000000..0a87f5423d --- /dev/null +++ b/tools/reference_stack_compose/up_seaweedfs-master.sh @@ -0,0 +1,2 @@ +docker compose up -d seaweedfs-master + diff --git a/tools/reference_stack_compose/up_seaweedfs-s3.sh b/tools/reference_stack_compose/up_seaweedfs-s3.sh new file mode 100644 index 0000000000..7f11f8e1ad --- /dev/null +++ b/tools/reference_stack_compose/up_seaweedfs-s3.sh @@ -0,0 +1,2 @@ +docker compose up -d seaweedfs-s3 + diff --git a/tools/reference_stack_compose/up_seaweedfs-volume.sh b/tools/reference_stack_compose/up_seaweedfs-volume.sh new file mode 100644 index 0000000000..b42cd1e322 --- /dev/null +++ b/tools/reference_stack_compose/up_seaweedfs-volume.sh @@ -0,0 +1,2 @@ +docker compose up -d seaweedfs-volume + diff --git a/tools/reference_stack_compose/up_wakapi-pg.sh b/tools/reference_stack_compose/up_wakapi-pg.sh new file mode 100644 index 0000000000..244f2bb275 --- /dev/null +++ b/tools/reference_stack_compose/up_wakapi-pg.sh @@ -0,0 +1,2 @@ +docker compose up -d wakapi-pg + diff --git a/tools/reference_stack_compose/up_wakapi-probe.sh b/tools/reference_stack_compose/up_wakapi-probe.sh new file mode 100644 index 0000000000..22dde5452b --- /dev/null +++ b/tools/reference_stack_compose/up_wakapi-probe.sh @@ -0,0 +1,2 @@ +docker compose up -d wakapi-probe + diff --git a/tools/reference_stack_compose/up_wakapi.sh b/tools/reference_stack_compose/up_wakapi.sh new file mode 100644 index 0000000000..89ee608614 --- /dev/null +++ b/tools/reference_stack_compose/up_wakapi.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# up_wakapi.sh — start/re-warm only the wakapi pair (hot-stack tooling: +# expects overleaf to be running for the IP pin). Same trigger-safe cycle as +# up.sh: preflight drop -> up -> apply. +set -euo pipefail +cd "$(dirname "$0")" + +docker compose up -d wakapi-pg +bash 0_ensure_local_auth_type.sh preflight + +ip="$(docker inspect -f '{{(index .NetworkSettings.Networks "overleaf-network").IPAddress}}' overleaf)" +grep -q '^OVERLEAF_IP=' .env && sed -i "s/^OVERLEAF_IP=.*/OVERLEAF_IP=$ip/" .env || echo "OVERLEAF_IP=$ip" >> .env +echo "pinned OVERLEAF_IP=$ip" + +docker compose up -d wakapi +bash 0_ensure_local_auth_type.sh apply diff --git a/tools/reference_stack_compose/wakapi-pg/.gitignore b/tools/reference_stack_compose/wakapi-pg/.gitignore new file mode 100644 index 0000000000..efe3c34f6f --- /dev/null +++ b/tools/reference_stack_compose/wakapi-pg/.gitignore @@ -0,0 +1,2 @@ +backup +data diff --git a/tools/reference_stack_compose/wakapi-pg/backup.sh b/tools/reference_stack_compose/wakapi-pg/backup.sh new file mode 100755 index 0000000000..72a7d8e290 --- /dev/null +++ b/tools/reference_stack_compose/wakapi-pg/backup.sh @@ -0,0 +1 @@ +docker exec wakapi-pg bash -c "pg_dump -U wakapi -d wakapi -F c -f /backup/backup.sql" diff --git a/tools/reference_stack_compose/wakapi-pg/compose.yaml b/tools/reference_stack_compose/wakapi-pg/compose.yaml new file mode 100644 index 0000000000..448a5a75f2 --- /dev/null +++ b/tools/reference_stack_compose/wakapi-pg/compose.yaml @@ -0,0 +1,28 @@ +services: + wakapi-pg: + image: ${POSTGRES_IMAGE} + container_name: wakapi-pg + hostname: wakapi-pg + restart: unless-stopped + environment: + POSTGRES_DB: wakapi + POSTGRES_USER: wakapi + POSTGRES_PASSWORD: ${PG_PASSWORD} + + networks: + - overleaf-network + + volumes: + - ./data:/var/lib/postgresql + - ./backup:/backup + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${WAKAPI_DB_USER:-wakapi} -d ${WAKAPI_DB_NAME:-wakapi}"] + interval: 5s + timeout: 3s + retries: 24 + start_period: 20s + +networks: + overleaf-network: + external: true diff --git a/tools/reference_stack_compose/wakapi-probe/compose.yaml b/tools/reference_stack_compose/wakapi-probe/compose.yaml new file mode 100644 index 0000000000..8713e8f6b1 --- /dev/null +++ b/tools/reference_stack_compose/wakapi-probe/compose.yaml @@ -0,0 +1,25 @@ +services: + wakapi-probe: + image: ${PROBE_IMAGE} + container_name: wakapi-probe + restart: unless-stopped + + networks: + - overleaf-network + + # NOTE: probe /login, NOT / — wakapi's base path is /wakapi and the raw + # server's 302 for / points Location: /wakapi/login, which only exists + # behind nginx's prefix-stripping; inside the network the follow-up is a + # 404 and spider mode would report failure. /login is base-path + # independent and always a plain 200. + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://wakapi:3000/login >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 20s + entrypoint: ["sh", "-c", "sleep infinity"] + +networks: + overleaf-network: + external: true diff --git a/tools/reference_stack_compose/wakapi/compose.yaml b/tools/reference_stack_compose/wakapi/compose.yaml new file mode 100644 index 0000000000..fd157bed7c --- /dev/null +++ b/tools/reference_stack_compose/wakapi/compose.yaml @@ -0,0 +1,87 @@ +services: + wakapi: + image: ${WAKA_API_IMAGE} + container_name: wakapi + hostname: wakapi + restart: unless-stopped + + environment: + WAKAPI_DB_TYPE: postgres + WAKAPI_DB_HOST: wakapi-pg + WAKAPI_DB_PORT: "5432" + WAKAPI_DB_USER: wakapi + WAKAPI_DB_PASSWORD: ${PG_PASSWORD} + WAKAPI_DB_NAME: wakapi + + # Public URL: used for outbound links (notably the password-reset + # email link). MUST be the public /wakapi route — the shipped default + # is http://localhost:3000, which would make every reset mail useless. + WAKAPI_PUBLIC_URL: "${OVERLEAF_SITE_URL}/wakapi" + + # ============ e-mail (2026-10-26): self-service password reset ============ + # Staff have no wakapi signup path (identity comes from Overleaf, + # see below); they obtain their UI password via "Forgot password?" + # on /wakapi/login → reset mail via the university SMTP relay + # (same OVERLEAF_EMAIL_* settings as Overleaf itself) → set-password + # link (built from WAKAPI_PUBLIC_URL) → password login. + # Prereq: users.auth_type is normalized to 'local' — applied + # automatically by 0_ensure_local_auth_type.sh (runs after up.sh / + # up_wakapi.sh; see its header for migration ordering + upgrade caveat). + # Reset rate limit ships at 5/hour (WAKAPI_PASSWORD_RESET_MAX_RATE) + # — fine for staff scale. + WAKAPI_MAIL_ENABLED: "true" + WAKAPI_MAIL_SENDER: "${OVERLEAF_EMAIL_FROM_ADDRESS}" + WAKAPI_MAIL_SMTP_HOST: "${OVERLEAF_EMAIL_SMTP_HOST}" + WAKAPI_MAIL_SMTP_PORT: "${OVERLEAF_EMAIL_SMTP_PORT}" + WAKAPI_MAIL_SMTP_USER: "${OVERLEAF_EMAIL_SMTP_USER}" + WAKAPI_MAIL_SMTP_PASS: "${OVERLEAF_EMAIL_PASSWORD}" + WAKAPI_MAIL_SMTP_TLS: "${WAKAPI_MAIL_SMTP_TLS}" + + # ============ identity model (2026-10-25): Overleaf-driven ============ + # Users are created AUTOMATICALLY from the Overleaf user base via + # trusted-header auth (see below) — NOT via signup. External + # registration is disabled on this box: + WAKAPI_ALLOW_SIGNUP: "false" + WAKAPI_OIDC_ALLOW_SIGNUP: "false" + + # Frontpage off (2026-10-26): anonymous '/' goes straight to the login + # page (which already carries the 'Forgot password?' reset form) + # instead of the marketing landing. Logged-in users are unaffected + # (root redirects to their dashboard first — routes/home.go). + WAKAPI_DISABLE_FRONTPAGE: "true" + + # Trusted-header auth (wakapi native, issue muety/wakapi#808): the + # Overleaf backend — the ONLY source IP listed below — asserts the + # logged-in Overleaf user's email in this header, and wakapi + # auto-creates the matching user on first use. Spoofing the header + # from any other IP is rejected (401). + WAKAPI_TRUSTED_HEADER_AUTH: "true" + WAKAPI_TRUSTED_HEADER_AUTH_KEY: "Remote-User" + WAKAPI_TRUSTED_HEADER_AUTH_ALLOW_SIGNUP: "true" + + # Trusted source IPs: overleaf container's address on overleaf-network + # (kept in /data_1/docker/compose/.env as OVERLEAF_IP). wakapi parses + # single IPs or CIDRs but NOT hostnames (silently dropped — + # /data_1/compare/wakapi config.go:442-462). For recreate-durability + # use the subnet: 172.18.0.0/16. + WAKAPI_TRUST_REVERSE_PROXY_IPS: "${OVERLEAF_IP}" + + # Served under /wakapi (nginx route in /data_1/docker/compose/nginx/nginx.conf): + # login, links, redirects and cookie paths all honor this base path. + WAKAPI_BASE_PATH: "/wakapi" + + networks: + - overleaf-network + + healthcheck: + # image has no shell/wget/ls (scratch family): liveness = the binary + # runs; HTTP serving is proven by wakapi-probe. + test: ["CMD", "/app/wakapi", "--version"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +networks: + overleaf-network: + external: true diff --git a/tools/reference_stack_compose/wakapi/ensure_local_auth_type.sql b/tools/reference_stack_compose/wakapi/ensure_local_auth_type.sql new file mode 100644 index 0000000000..b4610f1746 --- /dev/null +++ b/tools/reference_stack_compose/wakapi/ensure_local_auth_type.sql @@ -0,0 +1,40 @@ +-- wakapi-pg: normalize trusted-header-created user rows (idempotent). +-- Why (2026-10-26, staff SSO deployment): +-- Staff accounts are auto-created by Overleaf via trusted-header auth. +-- Wakapi stamps such rows with a non-local auth_type and leaves the +-- `email` column empty (wiped by migration 20250214-nullify-empty-email). +-- Both facts break the self-service path we need: +-- * routes/login.go (PostResetPassword) refuses resets for +-- auth_type != 'local' ("non-local user tried to reset password"), +-- * the reset lookup GetUserByEmail() misses rows with NULL email. +-- Result of the normalization: a header-created user is an ordinary +-- local-credential account (seeded by Overleaf) whose owner can use +-- "Forgot password?" at /wakapi/login. +-- Safety: +-- * UNIQUE idx_oidc (auth_type, sub): header rows have sub = NULL and +-- Postgres unique indexes ignore NULLs → unlimited ('local', NULL). +-- Real OIDC rows keep their (auth_type, sub) uniqueness. +-- * email := id only for id values that look like addresses; `id` for +-- trusted-header users IS the Overleaf user's email. +create or replace function wakapi_normalize_local_auth() +returns trigger language plpgsql as $$ +begin + if new.auth_type is distinct from 'local' then + new.auth_type := 'local'; + end if; + if (new.email is null or new.email = '') and position('@' in coalesce(new.id, '')) > 1 then + new.email := new.id; + end if; + return new; +end $$; + +drop trigger if exists trg_wakapi_normalize_local_auth on users; +create trigger trg_wakapi_normalize_local_auth +before insert or update of auth_type, email on users +for each row execute function wakapi_normalize_local_auth(); + +-- one-time backfill for rows created before the trigger existed +update users set auth_type = 'local' where auth_type is distinct from 'local'; +update users set email = id + where (email is null or email = '') + and position('@' in id) > 1;