mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
feat/wakatime integration- #249 plus tools/reference_stack_compose with wakapi
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -1039,6 +1039,10 @@ module.exports = {
|
||||
__dirname,
|
||||
'../modules/languagetool/frontend/js/languagetool-extension'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/wakatime/frontend/js/editor/wakatime-source-editor-extension.ts'
|
||||
),
|
||||
],
|
||||
sourceEditorVisualExtensions: [],
|
||||
sourceEditorComponents: [],
|
||||
@@ -1083,6 +1087,10 @@ module.exports = {
|
||||
__dirname,
|
||||
'../modules/github-sync/frontend/js/components/github-sync-widget.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/wakatime/frontend/js/components/wakatime-widget.tsx'
|
||||
),
|
||||
],
|
||||
referenceLinkingWidgets: [
|
||||
Path.resolve(
|
||||
@@ -1177,6 +1185,10 @@ module.exports = {
|
||||
__dirname,
|
||||
'../modules/zotero/frontend/js/components/zotero-integration-card.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/wakatime/frontend/js/components/wakatime-integration-card.tsx'
|
||||
),
|
||||
],
|
||||
referenceSearchSetting: [],
|
||||
settingsModalEditorTabSections: [],
|
||||
@@ -1218,7 +1230,24 @@ module.exports = {
|
||||
'git-bridge',
|
||||
'github-sync',
|
||||
'zotero',
|
||||
'wakatime',
|
||||
],
|
||||
wakatime: {
|
||||
// global on/off (widget + routes); default: on (matches PR 249 behavior)
|
||||
enabled: process.env.WAKATIME_ENABLED !== 'false',
|
||||
debugLogging: process.env.WAKATIME_DEBUG_LOGGING === 'true',
|
||||
// Deployment-managed identity (bundled Wakapi, see
|
||||
// tools/wakapi_server_compose): NO per-user registration, NO API keys —
|
||||
// the logged-in user's email is asserted to Wakapi via a trusted
|
||||
// header (WAKAPI_TRUSTED_HEADER_AUTH on the Wakapi side) and Wakapi
|
||||
// creates the user automatically on first use. A manually linked key
|
||||
// (wakatime.com or any other WakaTime server) always takes priority.
|
||||
sharedIdentity: process.env.WAKATIME_SHARED_IDENTITY === 'true',
|
||||
sharedIdentityApiUrl:
|
||||
process.env.WAKATIME_SHARED_IDENTITY_API_URL ||
|
||||
'http://wakapi:3000/api/compat/wakatime/v1',
|
||||
sharedIdentityHeader: process.env.WAKATIME_SHARED_IDENTITY_HEADER || 'Remote-User',
|
||||
},
|
||||
viewIncludes: {},
|
||||
|
||||
csp: {
|
||||
|
||||
@@ -12,7 +12,8 @@ const integrationPanelComponents = allIntegrationPanelComponents.filter(
|
||||
({ path }) =>
|
||||
(getMeta('ol-gitBridgeEnabled') || !path.includes('git-bridge')) &&
|
||||
(getMeta('ol-ExposedSettings').githubSyncEnabled || !path.includes('github-sync')) &&
|
||||
(getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero'))
|
||||
(getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero')) &&
|
||||
(getMeta('ol-ExposedSettings').wakaTimeEnabled || !path.includes('wakatime'))
|
||||
)
|
||||
|
||||
export default function IntegrationsPanel() {
|
||||
|
||||
@@ -20,10 +20,12 @@ const availableLangFeedbackLinkingWidgets = importOverleafModules(
|
||||
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
|
||||
const githubSyncEnabled = getMeta('ol-ExposedSettings').githubSyncEnabled
|
||||
const zoteroEnabled = getMeta('ol-ExposedSettings').zoteroEnabled
|
||||
const wakaTimeEnabled = getMeta('ol-ExposedSettings').wakaTimeEnabled
|
||||
|
||||
const availableIntegrationLinkingWidgets = allAvailableIntegrationLinkingWidgets.filter(
|
||||
({ path }) =>
|
||||
(githubSyncEnabled || !path.includes('github-sync'))
|
||||
(githubSyncEnabled || !path.includes('github-sync')) &&
|
||||
(wakaTimeEnabled || !path.includes('wakatime'))
|
||||
)
|
||||
|
||||
function LinkingSection() {
|
||||
@@ -55,7 +57,7 @@ function LinkingSection() {
|
||||
}[]
|
||||
|
||||
const renderSyncSection =
|
||||
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled
|
||||
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled || wakaTimeEnabled
|
||||
|
||||
const allIntegrationLinkingWidgets = integrationLinkingWidgets.concat(
|
||||
oauth2ServerComponents
|
||||
|
||||
@@ -513,7 +513,7 @@ class HistoryOTAdapter {
|
||||
|
||||
export const trackChangesAnnotation = Annotation.define()
|
||||
|
||||
const chooseOrigin = (transaction: Transaction) => {
|
||||
export const chooseOrigin = (transaction: Transaction) => {
|
||||
if (transaction.annotation(Transaction.remote)) {
|
||||
return 'remote'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
function WakaTimeLogo({ size = 40 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 340 340"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M170 20C87.156 20 20 87.156 20 170C20 252.844 87.156 320 170 320C252.844 320 320 252.844 320 170C320 87.156 252.844 20 170 20V20V20Z"
|
||||
stroke="black"
|
||||
strokeWidth="40"
|
||||
/>
|
||||
<path
|
||||
d="M190.183 213.541C188.74 215.443 186.576 216.667 184.151 216.667C183.913 216.667 183.677 216.651 183.443 216.627C183.042 216.579 182.823 216.545 182.606 216.497C182.337 216.434 182.137 216.375 181.94 216.308C181.561 216.176 181.392 216.109 181.228 216.035C180.843 215.849 180.707 215.778 180.572 215.701C180.205 215.478 180.109 215.412 180.014 215.345C179.856 215.233 179.698 215.117 179.547 214.992C179.251 214.746 179.147 214.65 179.044 214.552C178.731 214.241 178.531 214.018 178.341 213.785C177.982 213.331 177.69 212.888 177.438 212.415L168.6 198.214L159.766 212.415C158.38 214.939 155.874 216.667 152.995 216.667C150.106 216.667 147.588 214.926 146.243 212.346L107.607 156.061C106.337 154.529 105.556 152.499 105.556 150.258C105.556 145.514 109.043 141.665 113.344 141.665C116.127 141.665 118.564 143.282 119.942 145.708L152.555 193.9L161.735 178.952C163.058 176.288 165.626 174.478 168.575 174.478C171.273 174.478 173.652 175.996 175.049 178.298L184.517 193.839L235.684 120.583C237.075 118.226 239.475 116.667 242.213 116.667C246.514 116.667 250 120.514 250 125.258C250 127.332 249.337 129.232 248.23 130.715L190.183 213.541Z"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
strokeWidth="10"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default WakaTimeLogo
|
||||
@@ -0,0 +1,18 @@
|
||||
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
|
||||
|
||||
const { Schema } = mongoose
|
||||
const { ObjectId } = Schema
|
||||
|
||||
export const WakaTimeUserCredentialsSchema = new Schema(
|
||||
{
|
||||
userId: { type: ObjectId, ref: 'User', required: true, unique: true },
|
||||
apiUrl: { type: String, required: true },
|
||||
apiKeyEncrypted: { type: Schema.Types.Mixed, required: true },
|
||||
},
|
||||
{ collection: 'wakaTimeUserCredentials', minimize: false }
|
||||
)
|
||||
|
||||
export const WakaTimeUserCredentials = mongoose.model(
|
||||
'WakaTimeUserCredentials',
|
||||
WakaTimeUserCredentialsSchema,
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import Path from 'node:path'
|
||||
import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor'
|
||||
|
||||
const TOKEN_CIPHER_FILE = '/var/lib/overleaf/data/.wakatime-token-cipher.json'
|
||||
const TOKEN_CIPHER_LABEL = 'OL_CEP_wakatime-v3'
|
||||
|
||||
let encryptorInstance = null
|
||||
|
||||
/**
|
||||
* Get or create a stable encryptor data that persists across container
|
||||
* recreations. Priority:
|
||||
* 1. WAKATIME_TOKEN_CIPHER_PASSWORD / TOKEN_CIPHER_PASSWORD env var
|
||||
* 2. JSON file with encryptor data in the persistent volume (/var/lib/overleaf/data/)
|
||||
* — auto-generated on first use, survives container rebuilds
|
||||
*/
|
||||
function _getEncryptorData() {
|
||||
const cipherPassword = process.env.WAKATIME_TOKEN_CIPHER_PASSWORD || process.env.TOKEN_CIPHER_PASSWORD
|
||||
const cipherLabel = process.env.WAKATIME_TOKEN_CIPHER_LABEL ||
|
||||
process.env.TOKEN_CIPHER_LABEL ||
|
||||
TOKEN_CIPHER_LABEL
|
||||
if (cipherPassword) {
|
||||
return {
|
||||
cipherLabel,
|
||||
cipherPasswords: {
|
||||
[cipherLabel]: cipherPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
// cipherPassword is not set, use file
|
||||
const cipherFile = process.env.WAKATIME_TOKEN_CIPHER_FILE ||
|
||||
process.env.TOKEN_CIPHER_FILE ||
|
||||
TOKEN_CIPHER_FILE
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(cipherFile, 'utf8'))
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
logger.info({ cipherFile }, 'Creating new cipherFile')
|
||||
} else {
|
||||
logger.error({ err, cipherFile }, 'Bad cipherFile')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// File doesn't exist yet — generate one
|
||||
try {
|
||||
const encryptorData = {
|
||||
cipherLabel,
|
||||
cipherPasswords: {
|
||||
[cipherLabel]: crypto.randomBytes(32).toString('base64')
|
||||
},
|
||||
}
|
||||
|
||||
const dir = Path.dirname(cipherFile)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(cipherFile, JSON.stringify(encryptorData, null, 2), { mode: 0o600 })
|
||||
return encryptorData
|
||||
|
||||
} catch (err) {
|
||||
logger.error({ err, cipherFile }, 'Cannot create cipherFile')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function _getEncryptor() {
|
||||
if (!encryptorInstance) {
|
||||
const encryptorData = _getEncryptorData()
|
||||
encryptorInstance = new AccessTokenEncryptorClass(encryptorData)
|
||||
}
|
||||
return encryptorInstance
|
||||
}
|
||||
|
||||
export const AccessTokenEncryptor = {
|
||||
async encryptJson(json) {
|
||||
return await _getEncryptor().promises.encryptJson(json)
|
||||
},
|
||||
async decryptToJson(encryptedJson) {
|
||||
return await _getEncryptor().promises.decryptToJson(encryptedJson)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs'
|
||||
import { WakaTimeUserCredentials } from '../models/wakaTimeUserCredentials.mjs'
|
||||
import { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.mjs'
|
||||
|
||||
const { normalizeQuery } = Mongo
|
||||
|
||||
/**
|
||||
* Decrypt stored WakaTime credentials for a user.
|
||||
* Returns { apiUrl, apiKey } or null if not linked.
|
||||
*/
|
||||
async function getCredentials(userId) {
|
||||
const record = await WakaTimeUserCredentials.findOne(
|
||||
normalizeQuery({ userId })
|
||||
).exec()
|
||||
|
||||
if (!record?.apiKeyEncrypted) return null
|
||||
|
||||
try {
|
||||
const apiKey = await AccessTokenEncryptor.decryptToJson(
|
||||
record.apiKeyEncrypted
|
||||
)
|
||||
return { apiUrl: record.apiUrl, apiKey }
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ userId, err },
|
||||
'failed to decrypt WakaTime credentials, treating as not connected'
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a WakaTime (or Wakapi-compatible) account.
|
||||
*/
|
||||
async function storeCredentials(userId, apiUrl, apiKey) {
|
||||
const apiKeyEncrypted = await AccessTokenEncryptor.encryptJson(apiKey)
|
||||
await WakaTimeUserCredentials.findOneAndUpdate(
|
||||
normalizeQuery({ userId }),
|
||||
{ $set: { apiUrl, apiKeyEncrypted } },
|
||||
{ upsert: true }
|
||||
).exec()
|
||||
}
|
||||
|
||||
async function removeCredentials(userId) {
|
||||
await WakaTimeUserCredentials.deleteOne(normalizeQuery({ userId })).exec()
|
||||
}
|
||||
|
||||
export default {
|
||||
getCredentials,
|
||||
storeCredentials,
|
||||
removeCredentials,
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import Settings from '@overleaf/settings'
|
||||
import {
|
||||
fetchNothing,
|
||||
fetchJson,
|
||||
RequestFailedError,
|
||||
} from '@overleaf/fetch-utils'
|
||||
import {
|
||||
NotFoundError,
|
||||
TooManyRequestsError,
|
||||
ServiceNotConfiguredError,
|
||||
ForbiddenError,
|
||||
} from '../../../../app/src/Features/Errors/Errors.js'
|
||||
import TokenManager from './TokenManager.mjs'
|
||||
|
||||
const DEFAULT_API_URL = 'https://wakatime.com/api/v1'
|
||||
const REQUEST_TIMEOUT_MS = 15 * 1000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deployment-managed identity (tools/wakapi_server_compose, trusted-header)
|
||||
// ---------------------------------------------------------------------------
|
||||
// When this Overleaf deployment runs its own Wakapi server, users do NOT
|
||||
// register or paste keys: the logged-in Overleaf user's identity is asserted
|
||||
// to Wakapi via a trusted HTTP header (WAKAPI_TRUSTED_HEADER_AUTH on the
|
||||
// Wakapi side), and Wakapi auto-creates the matching user on first use.
|
||||
// A per-user stored key (manual wakatime.com flow) always takes priority,
|
||||
// so users who prefer their own wakatime.com account can still link one.
|
||||
// Configure via services/web/config/settings.defaults.js (wakatime block).
|
||||
|
||||
const W = Settings.wakatime || {}
|
||||
|
||||
function sharedIdentityActive() {
|
||||
return W.sharedIdentity === true
|
||||
}
|
||||
|
||||
function sharedAuthHeaders(email) {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
// Same UA rules as buildHeaders (wakapi user-agent parser).
|
||||
'User-Agent': 'wakatime/1.0.0 (linux) overleaf/1.0 overleaf-wakatime/1.0',
|
||||
[W.sharedIdentityHeader || 'Remote-User']: email,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve how to authenticate on behalf of a user, WITHOUT storing anything:
|
||||
* 1. stored credentials (manual wakatime.com / custom key) — always wins
|
||||
* 2. shared deployment identity (logged-in user's email via trusted header)
|
||||
* 3. nothing (caller decides the error)
|
||||
*/
|
||||
async function resolveAuth(userId, email) {
|
||||
const credentials = await TokenManager.getCredentials(userId)
|
||||
if (credentials) {
|
||||
return {
|
||||
kind: 'key',
|
||||
url: normalizeApiUrl(credentials.apiUrl),
|
||||
headers: buildHeaders(credentials.apiKey),
|
||||
}
|
||||
}
|
||||
if (sharedIdentityActive() && email) {
|
||||
return {
|
||||
kind: 'shared',
|
||||
url: normalizeApiUrl(W.sharedIdentityApiUrl),
|
||||
headers: sharedAuthHeaders(email),
|
||||
}
|
||||
}
|
||||
return { kind: 'none', url: null, headers: null }
|
||||
}
|
||||
|
||||
function normalizeApiUrl(apiUrl) {
|
||||
return (apiUrl || DEFAULT_API_URL).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function buildHeaders(apiKey, opts = {}) {
|
||||
return {
|
||||
Authorization: `Basic ${Buffer.from(apiKey).toString('base64')}`,
|
||||
'Content-Type': 'application/json',
|
||||
// Must start with "wakatime/" and have a recognized OS token in
|
||||
// parens next, or WakaTime/Wakapi's user-agent parser bails out and
|
||||
// reports the editor as "unknown" — see wakapi/utils/http.go
|
||||
// ParseUserAgent, which requires this exact shape.
|
||||
'User-Agent': 'wakatime/1.0.0 (linux) overleaf/1.0 overleaf-wakatime/1.0',
|
||||
...opts,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a (apiUrl, apiKey) pair works, without persisting anything.
|
||||
* Uses GET /users/current, which every WakaTime-compatible server
|
||||
* (wakatime.com and Wakapi) implements for auth verification.
|
||||
*/
|
||||
async function verifyCredentials(apiUrl, apiKey) {
|
||||
const url = normalizeApiUrl(apiUrl)
|
||||
try {
|
||||
await fetchJson(`${url}/users/current`, {
|
||||
headers: buildHeaders(apiKey),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
normalizeApiError(err, 'verifyCredentials')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the user has linked (and can still authenticate with)
|
||||
* a WakaTime-compatible account — or is covered by the deployment-managed
|
||||
* shared identity (this box's own Wakapi server, trusted-header auth).
|
||||
*/
|
||||
async function getConnectionStatus(userId, email) {
|
||||
const auth = await resolveAuth(userId, email)
|
||||
if (auth.kind === 'none') return { connected: false }
|
||||
|
||||
try {
|
||||
await fetchJson(`${auth.url}/users/current`, {
|
||||
headers: auth.headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
return {
|
||||
connected: true,
|
||||
apiUrl: auth.url,
|
||||
shared: auth.kind === 'shared' || undefined,
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ userId, kind: auth.kind, err }, 'WakaTime connection check failed')
|
||||
return { connected: false, apiUrl: auth.url, error: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single heartbeat on behalf of a user.
|
||||
* https://wakatime.com/developers#heartbeats
|
||||
*/
|
||||
async function sendHeartbeat(userId, email, heartbeat) {
|
||||
const auth = await resolveAuth(userId, email)
|
||||
if (auth.kind === 'none') {
|
||||
throw new ServiceNotConfiguredError({
|
||||
message: sharedIdentityActive()
|
||||
? 'WakaTime not configured for this user'
|
||||
: 'WakaTime credentials missing',
|
||||
info: { userId, status: 400 },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchNothing(`${auth.url}/users/current/heartbeats`, {
|
||||
method: 'POST',
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify(heartbeat),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
} catch (err) {
|
||||
normalizeApiError(err, 'sendHeartbeat')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a batch of heartbeats on behalf of a user.
|
||||
* https://wakatime.com/developers#heartbeats (bulk)
|
||||
*/
|
||||
async function sendHeartbeatsBulk(userId, email, heartbeats) {
|
||||
const auth = await resolveAuth(userId, email)
|
||||
if (auth.kind === 'none') {
|
||||
throw new ServiceNotConfiguredError({
|
||||
message: sharedIdentityActive()
|
||||
? 'WakaTime not configured for this user'
|
||||
: 'WakaTime credentials missing',
|
||||
info: { userId, status: 400 },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// WakaTime's /heartbeats.bulk endpoint accepts ONE heartbeat object per
|
||||
// request (same contract on wakapi: the .bulk route uses the single
|
||||
// heartbeat handler — batch semantics are throughput, not array body).
|
||||
// So a "bulk" send is a loop over the .bulk endpoint: sequential, at
|
||||
// most MAX_BULK_HEARTBEATS (50) per call, each 2xx-counting.
|
||||
for (const heartbeat of heartbeats) {
|
||||
await fetchNothing(`${auth.url}/users/current/heartbeats.bulk`, {
|
||||
method: 'POST',
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify(heartbeat),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
normalizeApiError(err, 'sendHeartbeatsBulk')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total tracked time for a single project, over the last `range` days
|
||||
* (default 7), by requesting a summaries range and summing the per-project
|
||||
* breakdown. Works against both wakatime.com and Wakapi.
|
||||
*/
|
||||
async function getProjectSummary(userId, email, projectName, range = 7) {
|
||||
const auth = await resolveAuth(userId, email)
|
||||
if (auth.kind === 'none') return null
|
||||
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - range * 24 * 60 * 60 * 1000)
|
||||
const qs = new URLSearchParams({
|
||||
start: start.toISOString().slice(0, 10),
|
||||
end: end.toISOString().slice(0, 10),
|
||||
project: projectName,
|
||||
})
|
||||
|
||||
try {
|
||||
const summary = await fetchJson(
|
||||
`${auth.url}/users/current/summaries?${qs.toString()}`,
|
||||
{
|
||||
headers: auth.headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}
|
||||
)
|
||||
|
||||
const totalSeconds = (summary.data || []).reduce((sum, day) => {
|
||||
const projectEntry = (day.projects || []).find(
|
||||
p => p.name === projectName
|
||||
)
|
||||
return sum + (projectEntry?.total_seconds || 0)
|
||||
}, 0)
|
||||
|
||||
return { totalSeconds, rangeDays: range }
|
||||
} catch (err) {
|
||||
normalizeApiError(err, 'getProjectSummary')
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkAccount(userId) {
|
||||
await TokenManager.removeCredentials(userId)
|
||||
}
|
||||
|
||||
function normalizeApiError(err, operation) {
|
||||
logger.error({ operation }, 'WakaTime API request failed')
|
||||
|
||||
if (err.name === 'AbortError') {
|
||||
throw new OError('WakaTime request timed out', {
|
||||
operation,
|
||||
status: 504,
|
||||
}).withCause(err)
|
||||
}
|
||||
|
||||
if (!(err instanceof RequestFailedError)) {
|
||||
throw new OError('Something wrong with WakaTime request', {
|
||||
operation,
|
||||
status: 500,
|
||||
}).withCause(err)
|
||||
}
|
||||
|
||||
const status = err.response?.status || 500
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
throw new ForbiddenError({
|
||||
message: 'Access denied',
|
||||
info: { operation, status },
|
||||
}).withCause(err)
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
throw new NotFoundError({
|
||||
message: 'Not found',
|
||||
info: { operation, status },
|
||||
}).withCause(err)
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
throw new TooManyRequestsError({
|
||||
message: 'Rate limit exceeded',
|
||||
info: { operation, status },
|
||||
}).withCause(err)
|
||||
}
|
||||
|
||||
throw new OError('WakaTime request error', { operation, status }).withCause(
|
||||
err
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
verifyCredentials,
|
||||
getConnectionStatus,
|
||||
sendHeartbeat,
|
||||
sendHeartbeatsBulk,
|
||||
getProjectSummary,
|
||||
unlinkAccount,
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
|
||||
import { Project } from '../../../../app/src/models/Project.mjs'
|
||||
import WakaTimeApiClient from './WakaTimeApiClient.mjs'
|
||||
import TokenManager from './TokenManager.mjs'
|
||||
|
||||
const MAX_BULK_HEARTBEATS = 50
|
||||
|
||||
// Deployment-managed identity: assert the LOGGED-IN user's email to the
|
||||
// bundled Wakapi via trusted-header auth (never a client-supplied value).
|
||||
function sessionEmail(req) {
|
||||
const user = SessionManager.getSessionUser(req.session)
|
||||
return user?.email || null
|
||||
}
|
||||
|
||||
function sendError(res, err, operation) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const status = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, operation }, 'WakaTime request failed')
|
||||
res.status(status).json({ message: err.message })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /user/wakatime/status
|
||||
*/
|
||||
async function getConnectionStatus(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const email = sessionEmail(req)
|
||||
try {
|
||||
const status = await WakaTimeApiClient.getConnectionStatus(userId, email)
|
||||
res.json(status)
|
||||
} catch (err) {
|
||||
sendError(res, err, 'getConnectionStatus')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /user/wakatime
|
||||
* body: { apiUrl, apiKey }
|
||||
* Verifies the credentials against the target server before storing them.
|
||||
*/
|
||||
async function link(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { apiUrl, apiKey } = req.body || {}
|
||||
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
return res.status(400).json({ message: 'apiKey is required' })
|
||||
}
|
||||
|
||||
try {
|
||||
await WakaTimeApiClient.verifyCredentials(apiUrl, apiKey)
|
||||
await TokenManager.storeCredentials(userId, apiUrl, apiKey)
|
||||
res.sendStatus(200)
|
||||
} catch (err) {
|
||||
sendError(res, err, 'link')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /user/wakatime
|
||||
*/
|
||||
async function unlink(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
try {
|
||||
await WakaTimeApiClient.unlinkAccount(userId)
|
||||
res.sendStatus(200)
|
||||
} catch (err) {
|
||||
sendError(res, err, 'unlink')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /project/:project_id/wakatime/heartbeat
|
||||
* body: a single WakaTime heartbeat payload (entity, type, time, ...)
|
||||
* Forwards the heartbeat to the user's configured WakaTime/Wakapi server,
|
||||
* filling in the project name server-side so the client never needs to
|
||||
* know (or spoof) it.
|
||||
*/
|
||||
async function heartbeat(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const email = sessionEmail(req)
|
||||
const { project_id: projectId } = req.params
|
||||
|
||||
try {
|
||||
const project = await Project.findById(projectId, 'name').exec()
|
||||
if (!project) return res.sendStatus(404)
|
||||
|
||||
await WakaTimeApiClient.sendHeartbeat(userId, email, {
|
||||
...req.body,
|
||||
project: project.name,
|
||||
})
|
||||
res.sendStatus(202)
|
||||
} catch (err) {
|
||||
sendError(res, err, 'heartbeat')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /project/:project_id/wakatime/heartbeats/bulk
|
||||
* body: an array of WakaTime heartbeat payloads
|
||||
*/
|
||||
async function heartbeatsBulk(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const email = sessionEmail(req)
|
||||
const { project_id: projectId } = req.params
|
||||
const heartbeats = Array.isArray(req.body) ? req.body : []
|
||||
|
||||
if (heartbeats.length === 0) return res.sendStatus(204)
|
||||
if (heartbeats.length > MAX_BULK_HEARTBEATS) {
|
||||
return res.status(400).json({ message: 'too many heartbeats in one batch' })
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await Project.findById(projectId, 'name').exec()
|
||||
if (!project) return res.sendStatus(404)
|
||||
|
||||
await WakaTimeApiClient.sendHeartbeatsBulk(
|
||||
userId,
|
||||
email,
|
||||
heartbeats.map(h => ({ ...h, project: project.name }))
|
||||
)
|
||||
res.sendStatus(202)
|
||||
} catch (err) {
|
||||
sendError(res, err, 'heartbeatsBulk')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /project/:project_id/wakatime/summary
|
||||
* Returns total tracked time (in seconds) for this project over the last
|
||||
* `range` days (query param, default 7).
|
||||
*/
|
||||
async function summary(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const email = sessionEmail(req)
|
||||
const { project_id: projectId } = req.params
|
||||
const range = Math.min(parseInt(req.query.range, 10) || 7, 90)
|
||||
|
||||
try {
|
||||
const project = await Project.findById(projectId, 'name').exec()
|
||||
if (!project) return res.sendStatus(404)
|
||||
|
||||
const result = await WakaTimeApiClient.getProjectSummary(
|
||||
userId,
|
||||
email,
|
||||
project.name,
|
||||
range
|
||||
)
|
||||
if (!result) return res.json({ connected: false })
|
||||
|
||||
res.json({ connected: true, ...result })
|
||||
} catch (err) {
|
||||
sendError(res, err, 'summary')
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getConnectionStatus,
|
||||
link,
|
||||
unlink,
|
||||
heartbeat,
|
||||
heartbeatsBulk,
|
||||
summary,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import WakaTimeController from './WakaTimeController.mjs'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
|
||||
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.debug({}, 'Init wakatime router')
|
||||
|
||||
// account-level linking (Account Settings)
|
||||
webRouter.get(
|
||||
'/user/wakatime/status',
|
||||
AuthenticationController.requireLogin(),
|
||||
WakaTimeController.getConnectionStatus
|
||||
)
|
||||
|
||||
webRouter.put(
|
||||
'/user/wakatime',
|
||||
AuthenticationController.requireLogin(),
|
||||
WakaTimeController.link
|
||||
)
|
||||
|
||||
webRouter.delete(
|
||||
'/user/wakatime',
|
||||
AuthenticationController.requireLogin(),
|
||||
WakaTimeController.unlink
|
||||
)
|
||||
|
||||
// per-project heartbeats, sent by the editor's activity tracker
|
||||
webRouter.post(
|
||||
'/project/:project_id/wakatime/heartbeat',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
WakaTimeController.heartbeat
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/project/:project_id/wakatime/heartbeats/bulk',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
WakaTimeController.heartbeatsBulk
|
||||
)
|
||||
|
||||
// total time tracked for a project, shown in the Integrations panel
|
||||
webRouter.get(
|
||||
'/project/:project_id/wakatime/summary',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
WakaTimeController.summary
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { putJSON } from '@/infrastructure/fetch-json'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import Notification from '@/shared/components/notification'
|
||||
|
||||
const DEFAULT_API_URL = 'https://wakatime.com/api/v1'
|
||||
|
||||
export default function WakaTimeConnectModal({
|
||||
show,
|
||||
handleHide,
|
||||
onLinked,
|
||||
}: {
|
||||
show: boolean
|
||||
handleHide: () => void
|
||||
onLinked: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [apiUrl, setApiUrl] = useState(DEFAULT_API_URL)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await putJSON('/user/wakatime', {
|
||||
body: { apiUrl: apiUrl.trim() || DEFAULT_API_URL, apiKey: apiKey.trim() },
|
||||
})
|
||||
setApiKey('')
|
||||
onLinked()
|
||||
handleHide()
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.data?.message ||
|
||||
err?.message ||
|
||||
t('generic_something_went_wrong')
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OLModal show={show} onHide={handleHide} backdrop="static">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('connect_wakatime', { defaultValue: 'Connect WakaTime' })}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<p className="small">
|
||||
{t('wakatime_connect_description', {
|
||||
defaultValue:
|
||||
'Works with wakatime.com or a self-hosted Wakapi server. Create an API key on your account’s settings page and paste it below.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<OLFormGroup controlId="wakatime-api-url">
|
||||
<OLFormLabel>{t('api_url', { defaultValue: 'API URL' })}</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="text"
|
||||
value={apiUrl}
|
||||
placeholder={DEFAULT_API_URL}
|
||||
onChange={e => setApiUrl(e.target.value)}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup controlId="wakatime-api-key">
|
||||
<OLFormLabel>{t('api_key', { defaultValue: 'API Key' })}</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="password"
|
||||
value={apiKey}
|
||||
required
|
||||
autoComplete="off"
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
{error && <Notification type="error" content={error} />}
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={handleHide} disabled={isSaving}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={isSaving || !apiKey.trim()}
|
||||
>
|
||||
{isSaving ? t('saving') : t('connect', { defaultValue: 'Connect' })}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</form>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import WakaTimeLogo from '@/shared/svgs/wakatime-logo'
|
||||
import IntegrationCard from '@/features/integrations-panel/integration-card'
|
||||
import WakaTimeConnectModal from './wakatime-connect-modal'
|
||||
|
||||
type SummaryResponse =
|
||||
| { connected: false }
|
||||
| { connected: true; totalSeconds: number; rangeDays: number }
|
||||
|
||||
function formatDuration(totalSeconds: number) {
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
if (hours === 0 && minutes === 0) return '0m'
|
||||
if (hours === 0) return `${minutes}m`
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
|
||||
function WakaTimeCard() {
|
||||
const { t } = useTranslation()
|
||||
const { project } = useProjectContext()
|
||||
const projectId = project?._id
|
||||
|
||||
const [summary, setSummary] = useState<SummaryResponse | null>(null)
|
||||
const [showConnectModal, setShowConnectModal] = useState(false)
|
||||
|
||||
const loadSummary = () => {
|
||||
if (!projectId) return
|
||||
getJSON<SummaryResponse>(`/project/${projectId}/wakatime/summary`)
|
||||
.then(setSummary)
|
||||
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||
}
|
||||
|
||||
useEffect(loadSummary, [projectId])
|
||||
|
||||
if (!projectId) return null
|
||||
|
||||
const isConnected = summary?.connected === true
|
||||
|
||||
const description = summary?.connected
|
||||
? t('wakatime_time_this_week', {
|
||||
defaultValue: '__duration__ tracked (last __days__ days)',
|
||||
duration: formatDuration(summary.totalSeconds),
|
||||
days: summary.rangeDays,
|
||||
})
|
||||
: t('wakatime_connect_to_track_time', {
|
||||
defaultValue: 'Connect WakaTime to track time on this project',
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegrationCard
|
||||
title="WakaTime"
|
||||
description={description}
|
||||
icon={<WakaTimeLogo size={32} />}
|
||||
showPaywallBadge={false}
|
||||
onClick={
|
||||
isConnected
|
||||
? undefined
|
||||
: () => setShowConnectModal(true)
|
||||
}
|
||||
href={
|
||||
isConnected
|
||||
? '/user/settings#project-sync'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<WakaTimeConnectModal
|
||||
show={showConnectModal}
|
||||
handleHide={() => setShowConnectModal(false)}
|
||||
onLinked={loadSummary}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WakaTimeCard
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getJSON, deleteJSON } from '@/infrastructure/fetch-json'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import WakaTimeLogo from '@/shared/svgs/wakatime-logo'
|
||||
import WakaTimeConnectModal from './wakatime-connect-modal'
|
||||
|
||||
type WakaTimeStatus = { connected: boolean; apiUrl?: string; error?: boolean }
|
||||
|
||||
/**
|
||||
* WakaTime account linking widget for the Account Settings page.
|
||||
* Unlike Zotero/GitHub, there's no OAuth handshake: the user pastes an
|
||||
* API key (from wakatime.com or a self-hosted Wakapi instance) directly.
|
||||
*
|
||||
* Registered via overleafModuleImports.integrationLinkingWidgets.
|
||||
*/
|
||||
export const WakaTimeWidget = function WakaTimeWidget() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
isLoading: isCheckingConn,
|
||||
isError: isErrorConnCheck,
|
||||
runAsync: runAsyncConnCheck,
|
||||
data: status,
|
||||
setData: setStatus,
|
||||
} = useAsync<WakaTimeStatus>()
|
||||
|
||||
const {
|
||||
isLoading: isUnlinking,
|
||||
isError: isErrorUnlink,
|
||||
runAsync: runAsyncUnlink,
|
||||
} = useAsync<void>()
|
||||
|
||||
const [showConnectModal, setShowConnectModal] = useState(false)
|
||||
|
||||
const handleConnCheck = useCallback(() => {
|
||||
runAsyncConnCheck(getJSON('/user/wakatime/status')).catch(err =>
|
||||
debugConsole.error(err?.data?.message || err?.message || err)
|
||||
)
|
||||
}, [runAsyncConnCheck])
|
||||
|
||||
useEffect(() => {
|
||||
handleConnCheck()
|
||||
}, [handleConnCheck])
|
||||
|
||||
const handleUnlink = useCallback(() => {
|
||||
runAsyncUnlink(deleteJSON('/user/wakatime'))
|
||||
.then(() => setStatus({ connected: false }))
|
||||
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||
}, [runAsyncUnlink, setStatus])
|
||||
|
||||
if (isCheckingConn) {
|
||||
return (
|
||||
<div className="settings-widget-container">
|
||||
<div>
|
||||
<WakaTimeLogo size={40} />
|
||||
</div>
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4>WakaTime</h4>
|
||||
</div>
|
||||
<p className="small">
|
||||
<span>{t('loading')}…</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isConnected = !!status?.connected
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-widget-container">
|
||||
<div>
|
||||
<WakaTimeLogo size={40} />
|
||||
</div>
|
||||
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4 id="wakatime-link">WakaTime</h4>
|
||||
</div>
|
||||
|
||||
<p className="small">
|
||||
{t('wakatime_widget_description', {
|
||||
defaultValue:
|
||||
'Track coding time in your projects with WakaTime or a self-hosted Wakapi server.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
{isConnected && status?.apiUrl && (
|
||||
<p className="small text-muted">{status.apiUrl}</p>
|
||||
)}
|
||||
|
||||
{isErrorConnCheck && (
|
||||
<Notification
|
||||
type="error"
|
||||
content={t('problem_checking_connection_with_provider', {
|
||||
provider: 'WakaTime',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isErrorUnlink && (
|
||||
<Notification type="error" content={t('generic_something_went_wrong')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isConnected ? (
|
||||
<OLButton
|
||||
variant="danger-ghost"
|
||||
onClick={handleUnlink}
|
||||
disabled={isUnlinking}
|
||||
>
|
||||
{isUnlinking ? t('unlinking') : t('unlink')}
|
||||
</OLButton>
|
||||
) : (
|
||||
<OLButton variant="secondary" onClick={() => setShowConnectModal(true)}>
|
||||
{t('link')}
|
||||
</OLButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WakaTimeConnectModal
|
||||
show={showConnectModal}
|
||||
handleHide={() => setShowConnectModal(false)}
|
||||
onLinked={handleConnCheck}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ViewPlugin } from '@codemirror/view'
|
||||
import type { DocumentContainer } from '@/features/ide-react/editor/document-container'
|
||||
import type { EditorFacade, ChangeDescription } from '@/features/source-editor/extensions/realtime'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
notifyFileOpened,
|
||||
notifyLocalEdit,
|
||||
notifyFileSaved,
|
||||
wakaTimeLog,
|
||||
} from './wakatime-tracker'
|
||||
|
||||
/**
|
||||
* Registered via overleafModuleImports.sourceEditorExtensions.
|
||||
*
|
||||
* Sends WakaTime heartbeats for genuinely local editor activity, without
|
||||
* touching the CodeMirror/ShareJS realtime plumbing directly. It relies on
|
||||
* the `origin` already computed by the realtime extension's `chooseOrigin`:
|
||||
* `origin === undefined` on a docChanged transaction means a real local
|
||||
* keystroke, as opposed to `'remote'` (a collaborator's edit applied via
|
||||
* OT), `'undo'` or `'reject'`.
|
||||
*/
|
||||
export const extension = (options: Record<string, any>) => {
|
||||
if (!getMeta('ol-ExposedSettings')?.wakaTimeEnabled) {
|
||||
wakaTimeLog('extension disabled: ol-ExposedSettings.wakaTimeEnabled is falsy')
|
||||
return []
|
||||
}
|
||||
|
||||
const currentDoc = options.currentDoc?.currentDoc as
|
||||
| DocumentContainer
|
||||
| undefined
|
||||
const docName = options.docName as string
|
||||
const projectId = getMeta('ol-project_id') as string
|
||||
|
||||
if (!currentDoc || !docName || !projectId) {
|
||||
wakaTimeLog('extension bailing out, missing data:', {
|
||||
hasCurrentDoc: !!currentDoc,
|
||||
docName,
|
||||
projectId,
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
wakaTimeLog('extension attached for', docName, 'in project', projectId)
|
||||
notifyFileOpened(projectId, docName)
|
||||
|
||||
const handleChange = (_editor: EditorFacade, change: ChangeDescription) => {
|
||||
if (change.origin === undefined) {
|
||||
notifyLocalEdit(projectId, docName)
|
||||
}
|
||||
}
|
||||
|
||||
let recentlyEditedLocally = false
|
||||
const markLocalEdit = (_editor: EditorFacade, change: ChangeDescription) => {
|
||||
if (change.origin === undefined) {
|
||||
recentlyEditedLocally = true
|
||||
}
|
||||
}
|
||||
|
||||
// document-container.ts dispatches this window event (not a `trigger` on
|
||||
// `currentDoc` itself) whenever the ShareJS doc for *any* open file has
|
||||
// drained its pending ops — filter to our own doc_id.
|
||||
const handleSaved = (event: Event) => {
|
||||
const { id } = (event as CustomEvent<{ id: string }>).detail || {}
|
||||
if (id === currentDoc.doc_id && recentlyEditedLocally) {
|
||||
notifyFileSaved(projectId, docName)
|
||||
recentlyEditedLocally = false
|
||||
}
|
||||
}
|
||||
|
||||
const attach = () => {
|
||||
wakaTimeLog('cm6 change listeners attached for', docName)
|
||||
currentDoc.cm6?.on('change', handleChange)
|
||||
currentDoc.cm6?.on('change', markLocalEdit)
|
||||
}
|
||||
|
||||
if (currentDoc.cm6) {
|
||||
attach()
|
||||
} else {
|
||||
wakaTimeLog('cm6 not ready yet, waiting for cm6:attach for', docName)
|
||||
currentDoc.on('cm6:attach', attach)
|
||||
}
|
||||
|
||||
window.addEventListener('doc:saved', handleSaved)
|
||||
|
||||
return ViewPlugin.define(() => ({
|
||||
destroy() {
|
||||
currentDoc.off('cm6:attach', attach)
|
||||
window.removeEventListener('doc:saved', handleSaved)
|
||||
currentDoc.cm6?.off('change', handleChange)
|
||||
currentDoc.cm6?.off('change', markLocalEdit)
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
const wakaTimeDebugLogging = !!getMeta('ol-ExposedSettings')?.wakaTimeDebugLogging
|
||||
|
||||
export function wakaTimeLog(...args: unknown[]) {
|
||||
if (wakaTimeDebugLogging) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[wakatime]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
// WakaTime clients only send a heartbeat if more than this long has passed
|
||||
// since the last one for the same file, or the file was switched/saved.
|
||||
const HEARTBEAT_THROTTLE_MS = 2 * 60 * 1000
|
||||
|
||||
// WakaTime/Wakapi identify the sending editor by parsing this string as
|
||||
// `wakatime/version (os) editorName/editorVersion editorName-wakatime/pluginVersion`
|
||||
// (the same convention every official wakatime-cli-based plugin uses).
|
||||
// Must start with "wakatime/" and have a recognized OS token in parens
|
||||
// next, or the parser bails out and reports the editor as "unknown".
|
||||
const USER_AGENT = 'wakatime/1.0.0 (linux) overleaf/1.0 overleaf-wakatime/1.0'
|
||||
|
||||
const EXTENSION_TO_LANGUAGE: Record<string, string> = {
|
||||
tex: 'LaTeX',
|
||||
ltx: 'LaTeX',
|
||||
sty: 'TeX',
|
||||
cls: 'TeX',
|
||||
bib: 'BibTeX',
|
||||
bbx: 'TeX',
|
||||
cbx: 'TeX',
|
||||
md: 'Markdown',
|
||||
rnw: 'R',
|
||||
py: 'Python',
|
||||
r: 'R',
|
||||
json: 'JSON',
|
||||
yml: 'YAML',
|
||||
yaml: 'YAML',
|
||||
}
|
||||
|
||||
function languageForFile(fileName: string): string | undefined {
|
||||
const match = /\.([^./]+)$/.exec(fileName)
|
||||
const ext = match?.[1]?.toLowerCase()
|
||||
return ext ? EXTENSION_TO_LANGUAGE[ext] : undefined
|
||||
}
|
||||
|
||||
const lastHeartbeatAt = new Map<string, number>()
|
||||
|
||||
function buildHeartbeat(docName: string, isWrite: boolean) {
|
||||
const heartbeat: Record<string, unknown> = {
|
||||
entity: docName,
|
||||
type: 'file',
|
||||
time: Date.now() / 1000,
|
||||
is_write: isWrite,
|
||||
user_agent: USER_AGENT,
|
||||
branch: 'main',
|
||||
}
|
||||
const language = languageForFile(docName)
|
||||
if (language) heartbeat.language = language
|
||||
return heartbeat
|
||||
}
|
||||
|
||||
function send(projectId: string, docName: string, isWrite: boolean) {
|
||||
const heartbeat = buildHeartbeat(docName, isWrite)
|
||||
wakaTimeLog('sending heartbeat', { projectId, docName, isWrite, heartbeat })
|
||||
postJSON(`/project/${projectId}/wakatime/heartbeat`, {
|
||||
body: heartbeat,
|
||||
}).then(
|
||||
res => wakaTimeLog('heartbeat sent OK', { docName, res }),
|
||||
err => {
|
||||
// WakaTime not linked, unreachable, etc — never surface this to the user
|
||||
wakaTimeLog('heartbeat FAILED', {
|
||||
docName,
|
||||
status: err?.response?.status,
|
||||
message: err?.message || err,
|
||||
data: err?.data,
|
||||
})
|
||||
debugConsole.log('wakatime heartbeat not sent', err?.message || err)
|
||||
}
|
||||
)
|
||||
lastHeartbeatAt.set(docName, Date.now())
|
||||
}
|
||||
|
||||
/** Called whenever the user opens/switches to a file in the editor. */
|
||||
export function notifyFileOpened(projectId: string, docName: string) {
|
||||
wakaTimeLog('notifyFileOpened', docName)
|
||||
send(projectId, docName, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on every local (non-collaborator, non-undo/reject) CodeMirror edit.
|
||||
* Throttled to at most one heartbeat per file per HEARTBEAT_THROTTLE_MS.
|
||||
*/
|
||||
export function notifyLocalEdit(projectId: string, docName: string) {
|
||||
const last = lastHeartbeatAt.get(docName) || 0
|
||||
if (Date.now() - last < HEARTBEAT_THROTTLE_MS) {
|
||||
wakaTimeLog('notifyLocalEdit throttled', docName)
|
||||
return
|
||||
}
|
||||
wakaTimeLog('notifyLocalEdit', docName)
|
||||
send(projectId, docName, false)
|
||||
}
|
||||
|
||||
/** Called when the document has just been saved (acked by ShareJS). */
|
||||
export function notifyFileSaved(projectId: string, docName: string) {
|
||||
wakaTimeLog('notifyFileSaved', docName)
|
||||
send(projectId, docName, true)
|
||||
}
|
||||
@@ -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
|
||||
@@ -56,6 +56,7 @@ export type ExposedSettings = {
|
||||
linkedInInsightsPartnerId?: string
|
||||
githubSyncEnabled: boolean
|
||||
zoteroEnabled: boolean
|
||||
wakaTimeEnabled: boolean
|
||||
languageToolAvailable: boolean
|
||||
enablePandocConversions: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
backup_seaweedfs_online
|
||||
|
||||
workbench
|
||||
@@ -0,0 +1,9 @@
|
||||
docker network create overleaf-network
|
||||
snetz=`docker network inspect overleaf-network | grep "Subnet" | sed s/" "/""/g | sed s/"\,"/""/g | sed s/":"/"\n"/g | grep -v "Subnet" | sed s/'"'/''/g | sed s/"{"/""/g`
|
||||
nid=`docker network ls | grep overleaf-network | awk '{print $1}'`
|
||||
|
||||
ufw allow in on br-$nid
|
||||
ufw route allow in on br-$nid
|
||||
ufw route allow out on br-$nid
|
||||
iptables -t nat -A POSTROUTING ! -o br-$nid -s $snetz -j MASQUERADE
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export AWS_ACCESS_KEY_ID
|
||||
export AWS_SECRET_ACCESS_KEY
|
||||
|
||||
ENDPOINT="http://localhost:8333"
|
||||
|
||||
echo "Testing connection to SeaweedFS S3 gateway..."
|
||||
aws --endpoint-url "$ENDPOINT" s3 ls
|
||||
|
||||
# Array of required Overleaf buckets
|
||||
BUCKETS=(
|
||||
"overleaf-user-files"
|
||||
"overleaf-template-files"
|
||||
"overleaf-project-blobs"
|
||||
"overleaf-chunks"
|
||||
)
|
||||
|
||||
echo "Checking and creating Overleaf buckets..."
|
||||
for bucket in "${BUCKETS[@]}"; do
|
||||
if ! aws --endpoint-url "$ENDPOINT" s3 ls "s3://$bucket" >/dev/null 2>&1; then
|
||||
echo "Creating bucket: s3://$bucket"
|
||||
aws --endpoint-url "$ENDPOINT" s3 mb "s3://$bucket"
|
||||
else
|
||||
echo "Bucket already exists: s3://$bucket"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Bucket setup complete. Current buckets:"
|
||||
aws --endpoint-url "$ENDPOINT" s3 ls
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# 0_ensure_local_auth_type.sh — manage wakapi's users-table normalization
|
||||
# (wakapi/ensure_local_auth_type.sql: trigger + one-time backfill).
|
||||
#
|
||||
# MODES
|
||||
# preflight Drop the trigger (no-op if absent / if the table doesn't
|
||||
# exist yet). Run BEFORE starting or recreating wakapi.
|
||||
# apply Wait for wakapi to serve, then (re)apply trigger + backfill.
|
||||
# Run AFTER wakapi is up. This is the default mode.
|
||||
#
|
||||
# WHY (twice-learned rule, 2026-10-26):
|
||||
# A trigger referencing `users` BLOCKS wakapi's pending startup DDL —
|
||||
# `ERROR: cannot alter type of a column used in a trigger definition
|
||||
# (SQLSTATE 0A000)` — and wakapi crashloops until the trigger is gone.
|
||||
# The batches are recorded per-run; while any of them is unrecorded, EVERY
|
||||
# startup re-runs the batch and can hit the collision. So the only safe
|
||||
# cycle is: drop -> wakapi up (batch runs free, records) -> apply.
|
||||
# up.sh and up_wakapi.sh do exactly this. Rows written during the short
|
||||
# window without the trigger are covered by the apply-time backfill.
|
||||
#
|
||||
# The SQL itself is idempotent (create or replace / drop if exists /
|
||||
# guarded updates), so the dance costs a few SQL statements per `up`.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
MODE="${1:-apply}"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
set -a; . ./.env; set +a
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
PGCLIENT="docker run --rm --network overleaf-network -e PGPASSWORD=${PG_PASSWORD}
|
||||
-e PGUSER=${WAKAPI_DB_USER:-wakapi} -e PGDATABASE=${WAKAPI_DB_NAME:-wakapi}
|
||||
${POSTGRES_IMAGE}"
|
||||
|
||||
wait_pg() {
|
||||
for i in $(seq 1 60); do
|
||||
${PGCLIENT} sh -c 'pg_isready -h wakapi-pg -p 5432' >/dev/null 2>&1 && return 0
|
||||
sleep 2
|
||||
done
|
||||
die "wakapi-pg not accepting connections after 120s"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
preflight)
|
||||
echo "[preflight/1] waiting for wakapi-pg (needed to drop the trigger)..."
|
||||
wait_pg
|
||||
echo "[preflight/2] dropping normalization trigger (if any) so wakapi startup DDL can run..."
|
||||
${PGCLIENT} psql -h wakapi-pg -p 5432 -v ON_ERROR_STOP=1 -c \
|
||||
"do \$\$ begin execute 'drop trigger if exists trg_wakapi_normalize_local_auth on users'; exception when undefined_object then null; end \$\$;" \
|
||||
>/dev/null
|
||||
echo "[preflight] done (no-op if the trigger wasn't there)"
|
||||
;;
|
||||
apply)
|
||||
echo "[1/3] waiting for wakapi-pg to accept connections..."
|
||||
wait_pg
|
||||
|
||||
echo "[2/3] waiting for users table + wakapi serving (startup migrations complete)..."
|
||||
READY=0
|
||||
for i in $(seq 1 90); do
|
||||
TBL=$( ${PGCLIENT} psql -h wakapi-pg -p 5432 -tAq -c "select to_regclass('public.users')")
|
||||
if [ "$TBL" = "users" ] \
|
||||
&& docker run --rm --network overleaf-network ${PROBE_IMAGE} \
|
||||
wget -q --spider http://wakapi:3000/login >/dev/null 2>&1; then
|
||||
READY=1; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
[ "$READY" = "1" ] \
|
||||
|| die "users table or wakapi not ready/serving after 180s — check: bash logs_wakapi.sh (look for 'migration failed')"
|
||||
|
||||
echo "[3/3] applying wakapi/ensure_local_auth_type.sql ..."
|
||||
docker run --rm --network overleaf-network \
|
||||
-e PGPASSWORD="${PG_PASSWORD}" \
|
||||
-e PGUSER="${WAKAPI_DB_USER:-wakapi}" \
|
||||
-e PGDATABASE="${WAKAPI_DB_NAME:-wakapi}" \
|
||||
-v "$(pwd)/wakapi/ensure_local_auth_type.sql:/init.sql:ro" \
|
||||
${POSTGRES_IMAGE} \
|
||||
psql -h wakapi-pg -p 5432 -v ON_ERROR_STOP=1 -f /init.sql
|
||||
|
||||
echo "active trigger(s) on users:"
|
||||
${PGCLIENT} psql -h wakapi-pg -p 5432 -tAq \
|
||||
-c "select ' '||tgname from pg_trigger where tgrelid='public.users'::regclass and not tgisinternal order by 1"
|
||||
echo "OK — local-auth normalization is active (staff 'Forgot password?' path works)."
|
||||
;;
|
||||
*)
|
||||
die "unknown mode '$MODE' (expected: preflight | apply)"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,2 @@
|
||||
sudo apt update
|
||||
sudo apt install -y awscli
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Path to your .env file (adjust if your script runs from a different directory)
|
||||
ENV_FILE="./.env"
|
||||
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found at $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure AWS variables are exported for the AWS CLI
|
||||
export AWS_ACCESS_KEY_ID
|
||||
export AWS_SECRET_ACCESS_KEY
|
||||
|
||||
ENDPOINT="http://localhost:8333"
|
||||
BACKUP_DIR="./backup_seaweedfs_online"
|
||||
TIMESTAMP=$(date +%F_%H-%M-%S)
|
||||
|
||||
mkdir -p "$BACKUP_DIR/$TIMESTAMP"
|
||||
|
||||
echo "1. Capturing online metadata snapshot..."
|
||||
docker exec seaweedfs-filer weed shell -master="master:9333" <<< "fs.meta.save /data/filer_meta_$TIMESTAMP.json"
|
||||
|
||||
echo "2. Syncing S3 buckets live..."
|
||||
for bucket in overleaf-user-files overleaf-template-files overleaf-project-blobs overleaf-chunks; do
|
||||
echo "Syncing s3://$bucket..."
|
||||
aws --endpoint-url "$ENDPOINT" s3 sync "s3://$bucket" "$BACKUP_DIR/$TIMESTAMP/$bucket"
|
||||
done
|
||||
|
||||
echo "Online backup completed successfully at $TIMESTAMP"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker pull ${TEX_LIVE_REPO}/texlive-full:2026.1
|
||||
docker pull ${TEX_LIVE_REPO}/texlive-full:2025.1
|
||||
docker pull ${TEX_LIVE_REPO}/texlive-full:2024.1
|
||||
docker pull ${TEX_LIVE_REPO}/texlive-full:2023.1
|
||||
|
||||
docker pull nginx:stable-alpine
|
||||
docker pull mongo:${MONGO_VERSION}
|
||||
docker pull redis:${REDIS_VERSION}
|
||||
docker pull chrislusf/seaweedfs:latest
|
||||
@@ -0,0 +1,180 @@
|
||||
# Overleaf CE — self-hosted stack (`psintern.neuro.uni-bremen.de`)
|
||||
|
||||
One Docker Compose project that runs the entire server: Overleaf CE (languagetool CE
|
||||
fork), its storage backend (MongoDB + Redis + SeaweedFS/S3), Git sync (git-bridge),
|
||||
grammar checking (LanguageTool), WakaTime-style activity tracking (Wakapi), and the
|
||||
TLS edge (nginx). Everything runs in the shared external network **`overleaf-network`**.
|
||||
|
||||
- Public URL: `https://psintern.neuro.uni-bremen.de` (Overleaf)
|
||||
- Wakapi UI: `https://psintern.neuro.uni-bremen.de/wakapi/` (same host, nginx subpath)
|
||||
- Server source: **not this repo** — the Overleaf image is built from the
|
||||
`overleaf-cep` checkout (`ext-ce-v6.3.1` branch); this directory only *deploys*.
|
||||
|
||||
---
|
||||
|
||||
## 1. Layout
|
||||
|
||||
```
|
||||
compose.yaml # root project: composes all services below via `extends`,
|
||||
# adds depends_on ordering, declares the external network
|
||||
<service>/compose.yaml # one service per directory (see table)
|
||||
.env # ALL configuration incl. secrets — git-ignored, never commit
|
||||
env.sample # committed, secret-free template of .env (cp env.sample .env)
|
||||
0_*.sh # one-time setup (run once, in this order)
|
||||
up_*.sh down_*.sh # per-service lifecycle (they cd to the project root themselves)
|
||||
logs_*.sh exec_*.sh # per-service logs / shell
|
||||
autoupdate_*.sh # pull next image version for a service (then up_*.sh)
|
||||
cycle_{nginx,overleaf}.sh
|
||||
0_ensure_local_auth_type.sh # wakapi users-table normalization trigger, 2 modes:
|
||||
# preflight — drop trigger (BEFORE wakapi up, unblocks its DDL)
|
||||
# apply — trigger + backfill (AFTER wakapi serves, default)
|
||||
# Both run automatically from up.sh / up_wakapi.sh.
|
||||
# Manual: bash 0_ensure_local_auth_type.sh [preflight|apply]
|
||||
up.sh / down.sh # the whole project (up.sh first auto-pins OVERLEAF_IP, see §5)
|
||||
<service>/data* # live data volumes (mongo, redis, seaweedfs-*, overleaf, ...) — git-ignored
|
||||
<service>/backup/ # backup dumps (git-ignored)
|
||||
backup_seaweedfs_online/# timestamped SeaweedFS online backups (git-ignored)
|
||||
<service>/backup.sh # mongo/, wakapi-pg/: dump into <service>/backup/
|
||||
```
|
||||
|
||||
### Services
|
||||
|
||||
| dir | container | image (env pin) | role |
|
||||
|---|---|---|---|
|
||||
| `overleaf/` | `overleaf` | `sharelatex/sharelatex:${OVERLEAF_VERSION}` | the app (CE fork). `./data` = /var/lib/overleaf, `./log` = /var/log/overleaf, docker.sock mounted |
|
||||
| `mongo/` | `mongo` | `mongo:${MONGO_VERSION}` | documents/projects. Runs as replica set `overleaf`. `./data_db`, `./backup` |
|
||||
| `redis/` | `redis` | `redis:${REDIS_VERSION}` | sessions/queues. AOF persistence. `./data`, `./backup` |
|
||||
| `seaweedfs-{master,volume,filer,s3}/` | same | `chrislusf/seaweedfs:latest` | S3-compatible file storage for overleaf (compiles, uploads, exports). Host ports bound to 127.0.0.1 only (9333/19333 master, 18080 volume gRPC, 8888 filer, 8333 S3 API). In-network aliases `master`, `volume`, `filer`, `s3` |
|
||||
| `git-bridge/` | `git-bridge` | `quay.io/sharelatex/git-bridge:${GIT_BRIDE_VERSION}` | Git export/sync for projects (texlive repo `TEX_LIVE_REPO`) |
|
||||
| `languagetool/` | `languagetool` | `erikvl87/languagetool:latest` | grammar checking (enabled in overleaf: `LANGUAGE_TOOL_*`); ngram models in `./ngrams` |
|
||||
| `wakapi-pg/` | `wakapi-pg` | `postgres:${POSTGRES_IMAGE}` | Wakapi's database (db/user `wakapi`, pw `${PG_PASSWORD}`). `./data`, `./backup` |
|
||||
| `wakapi/` | `wakapi` | `${WAKA_API_IMAGE}` (stock ghcr or local build) | WakaTime-compatible time tracking, self-hosted |
|
||||
| `wakapi-probe/` | `wakapi-probe` | `${PROBE_IMAGE}` (busybox, `sleep infinity`) | nothing it runs — a *healthcheck-only* container: "HTTP serving" proof for wakapi; nginx depends on it being healthy |
|
||||
| `nginx/` | `nginx` | `nginx:stable-alpine` | TLS edge (Let's Encrypt files from `SSL_KEY_FILE`/`SSL_CERT_FILE`), ACME dir, serves Overleaf at `/` and Wakapi at `/wakapi/` |
|
||||
|
||||
Startup order (enforced by `depends_on: service_healthy` in the root file):
|
||||
`redis, mongo, seaweedfs-* → overleaf → nginx`; `wakapi-pg → wakapi → wakapi-probe → nginx`;
|
||||
`git-bridge`, `languagetool` independent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuration (`.env`)
|
||||
|
||||
- **`env.sample` is the source of truth for *what keys exist* and what they mean** —
|
||||
copy it (values included where safe), then fill the `<PLACEHOLDER>`s. `.env` is
|
||||
git-ignored; **never** commit it, and **never** paste values from it into
|
||||
`env.sample` (it's the template, not a backup).
|
||||
- Plain `KEY=value` lines. Compose interpolates `${OTHER_KEY}` between keys, but
|
||||
**no shell**: `$(docker ...)`, backticks, `;` do **not** run — a line like
|
||||
`FOO=$(cmd)` stores the literal string. (Wakapi, e.g., would then warn and drop it.)
|
||||
- Secrets in play: AWS S3 keys (SeaweedFS S3 API), Zotero app creds, GitHub App
|
||||
creds, Keycloak OIDC client, invite-token secret, Overleaf SMTP password,
|
||||
wakapi-pg password. Generator hints are in the comments.
|
||||
- `OVERLEAF_IP` is **auto-maintained**: `up.sh` / `up_wakapi.sh` rewrite it from
|
||||
`docker inspect overleaf` before bringing (re)created containers up. Don't fight it.
|
||||
|
||||
---
|
||||
|
||||
## 3. First-time setup order
|
||||
|
||||
```bash
|
||||
bash 0_create_network.sh # overleaf-network + ufw/iptables for it
|
||||
bash 0_install_aws.sh # aws cli (for bucket ops)
|
||||
bash 0_create_s3_buckets.sh # seaweedfs S3 buckets for overleaf
|
||||
bash 0_pull_other_containers.sh # image pulls
|
||||
cp env.sample .env && vi .env # fill secrets
|
||||
bash up.sh # everything (pins OVERLEAF_IP first)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Day-to-day
|
||||
|
||||
```bash
|
||||
bash up_<svc>.sh # start/recreate one service (mongo, redis, overleaf, nginx,
|
||||
# wakapi, wakapi-pg, wakapi-probe, seaweedfs-*, git-bridge, languagetool)
|
||||
bash down_<svc>.sh # stop+remove it
|
||||
bash logs_<svc>.sh # follow logs
|
||||
bash exec_<svc>.sh # interactive shell (where the image allows)
|
||||
bash cycle_nginx.sh # down+up nginx (config change, cert renewal)
|
||||
bash up.sh # whole project
|
||||
# backups
|
||||
bash mongo/backup.sh # mongodump -> mongo/backup/
|
||||
bash wakapi-pg/backup.sh # pg_dump -> wakapi-pg/backup/
|
||||
# see backup_seaweedfs_online/ + 0_online_backup_s3.sh for the seaweedfs side
|
||||
```
|
||||
|
||||
**Image upgrades:** bump the `*_VERSION` / `WAKA_API_IMAGE` pins in `.env`
|
||||
(or edit the `image:` line in the service's compose.yaml), then `bash up_<svc>.sh` —
|
||||
compose replaces the container. `autoupdate_*.sh` do the pin-bump for
|
||||
mongo/redis/nginx/languagetool/seaweedfs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Special behaviors that are easy to step on
|
||||
|
||||
- **Overleaf rebuilds**: the image comes from the `overleaf-cep` checkout
|
||||
(`ext-ce-v6.3.1`). Rebuild there (see that repo's `server-ce/` + the
|
||||
`rebuild-overleaf-docker-image` skill), tag/push per `.env`'s `OVERLEAF_VERSION`,
|
||||
then `bash up_overleaf.sh`. A plain `docker compose up` does **not** swap a
|
||||
same-tag image unless pulled/retagged.
|
||||
- **Wakapi identity model** (no external signup, no per-user keys):
|
||||
- `WAKAPI_ALLOW_SIGNUP=false`, trusted-header auth on: Overleaf (the **only** trusted
|
||||
source IP — `WAKAPI_TRUST_REVERSE_PROXY_IPS` = `OVERLEAF_IP`) asserts the logged-in
|
||||
user's email in `Remote-User`; wakapi auto-creates/identifies the user.
|
||||
Hostname values in that list are **silently dropped** by wakapi (IP/CIDR only).
|
||||
- Staff get a UI password **self-service**: `…/wakapi/login` → "Forgot password?"
|
||||
→ reset mail via the university relay (`OVERLEAF_EMAIL_*`, sender
|
||||
`overleaf@uni-bremen.de`) → set password. Requires `WAKAPI_PUBLIC_URL`
|
||||
(`${OVERLEAF_SITE_URL}/wakapi` — the ship default `http://localhost:3000` would
|
||||
mail useless links).
|
||||
- **Normalization trigger for trusted-header users** (kept in the DB, not in
|
||||
wakapi): `wakapi/ensure_local_auth_type.sql` — a PG trigger on `wakapi-pg`
|
||||
rewriting header-created rows to `auth_type='local'` + `email=id`, else
|
||||
wakapi refuses the "Forgot password?" reset (routes/login.go).
|
||||
**Fully automatic safe cycle** (see `0_ensure_local_auth_type.sh` header):
|
||||
`up.sh` / `up_wakapi.sh` first run `preflight` (drop the trigger if present)
|
||||
so wakapi's startup DDL can never be blocked by it, start wakapi (pending
|
||||
batches run free and record), then `apply` (trigger + backfill — the
|
||||
backfill also covers rows written in the short unpinned window). This is
|
||||
mandatory while **any** wakapi migration batch is unrecorded: then every
|
||||
startup re-runs the batch and hits the trigger (`SQLSTATE 0A000`, the
|
||||
2026-10-26 crashloop). Once all batches are recorded the drop/apply dance
|
||||
remains a cheap no-cost safety net (e.g. after wipes or wakapi upgrades).
|
||||
**Only if you start wakapi manually** outside those scripts
|
||||
(`docker compose up -d wakapi`), run the preflight first:
|
||||
`bash 0_ensure_local_auth_type.sh preflight`
|
||||
- **Wakapi base path**: the app serves at `/` and only shapes *generated* URLs with
|
||||
`WAKAPI_BASE_PATH=/wakapi`. That's why nginx does
|
||||
`location /wakapi/ { proxy_pass http://wakapi:3000/; }` — the **trailing slash**
|
||||
strips the prefix. Removing it 404s the UI.
|
||||
- **nginx strips `Remote-User` on browser traffic** (`proxy_set_header Remote-User ""`)
|
||||
so clients cannot inject identities; only the overleaf container (pinned IP) may.
|
||||
- **mongo** runs `--replSet overleaf` with the init-in-healthcheck trick, and
|
||||
`GLIBC_TUNABLES: glibc.cpu.hwcaps=-SHSTK` (CPU feature-hole workaround — don't
|
||||
"clean" it).
|
||||
- **seaweedfs**: four cooperating containers; in-network names are the classic
|
||||
`master/filer/volume/s3` (aliases), host ports are loopback-only. The volume
|
||||
deliberately does **not** publish 8080 to the host (collision) — keep it that way.
|
||||
- **Health is load-bearing**: `nginx` waits for `overleaf` + `wakapi-probe`
|
||||
(healthchecks) before being healthy; `wakapi-probe` is an idle busybox whose
|
||||
healthcheck is the wakapi HTTP probe.
|
||||
- **UFW/iptables**: `0_create_network.sh` opens the bridge interface — after
|
||||
reboots or network recreation, make sure these rules still exist.
|
||||
- **Cold start fails with `Conflict. The container name "/<svc>" is already in
|
||||
use`**: an orphan container from another project (e.g. the old
|
||||
`tools/*_server_compose` recipes) still holds the name and `down.sh` didn't
|
||||
remove it (it's not this project's). Find it: `docker ps -a --filter name=<svc>`,
|
||||
then `docker rm <name>` (volumes, if any, stay on disk) and re-run `up.sh`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Related locations
|
||||
|
||||
- Overleaf server source & build: `overleaf-cep` checkout, branch `ext-ce-v6.3.1`
|
||||
(modules: `services/web/modules/{wakime,zotero,track-changes,…}`)
|
||||
- Wakapi upstream source checkout (for template/feature work against a pinned tag):
|
||||
`/data_1/compare/wakapi` (has tags incl. `2.18.2`)
|
||||
- Skills with the muscle memory for this box:
|
||||
`overleaf-dev-server-testing`, `rebuild-overleaf-docker-image`,
|
||||
`overleaf-agent-workflows`
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_NAME="languagetool"
|
||||
IMAGE_NAME="erikvl87/languagetool:latest"
|
||||
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="mongo"
|
||||
IMAGE_NAME="mongo:${MONGO_VERSION}"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="nginx"
|
||||
IMAGE_NAME="nginx:stable-alpine"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="redis"
|
||||
IMAGE_NAME="redis:${REDIS_VERSION}"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-filer"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-master"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-s3"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-volume"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME=${COMPOSE_DIRECTORY}
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -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
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
docker compose down nginx
|
||||
docker compose up -d nginx
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
docker compose down overleaf
|
||||
docker compose up -d overleaf
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down git-bridge
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
docker compose down languagetool
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down mongo
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down nginx
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down overleaf
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down redis
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down seaweedfs-filer
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down seaweedfs-master
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down seaweedfs-s3
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down seaweedfs-volume
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down wakapi-pg
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down wakapi-probe
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down wakapi
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# ============================================================================
|
||||
# env.sample — template for /data_1/docker/compose/.env
|
||||
#
|
||||
# HOW TO USE: cp env.sample .env then fill in the <PLACEHOLDER> values.
|
||||
# .env is git-ignored and is the ONLY place where real secrets live; compose
|
||||
# injects these into the containers (no shell expansion in .env values —
|
||||
# ${VAR} references work between keys, $(cmd) does NOT).
|
||||
#
|
||||
# NOTE: some values are auto-maintained (marked below) — do not hand-edit
|
||||
# those or the change will be overwritten on the next up.
|
||||
#
|
||||
# THIS FILE CONTAINS NO SECRETS (it is committed). Never copy real values
|
||||
# from .env into here.
|
||||
# ============================================================================
|
||||
|
||||
# Image versions
|
||||
OVERLEAF_VERSION=ext-ce-v6.3.1
|
||||
MONGO_VERSION=9.0
|
||||
REDIS_VERSION=8.10-alpine
|
||||
GIT_BRIDE_VERSION=6.3.1
|
||||
|
||||
# Global
|
||||
# You need here the correct path (the compose.yaml is in /docker/compose_cep/overleaf)
|
||||
COMPOSE_DIRECTORY=/data_1/docker/compose
|
||||
TEX_LIVE_REPO=git.fb1.uni-bremen.de/davrot
|
||||
OVERLEAF_LEFT_FOOTER='[]'
|
||||
OVERLEAF_LOGIN_SUPPORT_TEXT="<img src=\"ub/logo.svg\" height=\"250\"></br><a href=\"https://sso.fb1.uni-bremen.de\"> University of Bremen Overleaf Wiki </a>"
|
||||
|
||||
|
||||
COMPILES_HOST_DIR=${COMPOSE_DIRECTORY}/overleaf/data/data/compiles
|
||||
COMPILES_HOST_DIR_CACHE=${COMPOSE_DIRECTORY}/overleaf/data/data/cache
|
||||
CONVERSION_SANDBOX_HOST_DIR=${COMPOSE_DIRECTORY}/overleaf/data/tmp/conversions
|
||||
OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE=davrot@uni-bremen.de
|
||||
|
||||
# Nginx SSL
|
||||
SSL_KEY_FILE=/etc/letsencrypt/live/psintern.neuro.uni-bremen.de/privkey.pem
|
||||
SSL_CERT_FILE=/etc/letsencrypt/live/psintern.neuro.uni-bremen.de/fullchain.pem
|
||||
|
||||
#S3 Buckets
|
||||
# openssl rand -base64 30 | tr -dc 'a-zA-Z0-9' | head -c 20; echo
|
||||
AWS_ACCESS_KEY_ID=<AWS_ACCESS_KEY_ID>
|
||||
# openssl rand -base64 60 | tr -dc 'a-zA-Z0-9' | head -c 40; echo
|
||||
AWS_SECRET_ACCESS_KEY=<AWS_SECRET_ACCESS_KEY>
|
||||
|
||||
# Zotero
|
||||
# (credentials from your Zotero account: Settings -> Advanced -> Credentials)
|
||||
ZOTERO_CLIENT_KEY=<ZOTERO_CLIENT_KEY>
|
||||
ZOTERO_CLIENT_SECRET=<ZOTERO_CLIENT_SECRET>
|
||||
|
||||
# Github
|
||||
# (from your GitHub App settings)
|
||||
GITHUB_SYNC_CLIENT_ID=<GITHUB_SYNC_CLIENT_ID>
|
||||
GITHUB_SYNC_CLIENT_SECRET=<GITHUB_SYNC_CLIENT_SECRET>
|
||||
|
||||
# openssl rand -hex 32; echo
|
||||
OVERLEAF_INVITE_TOKEN_SECRET=<OVERLEAF_INVITE_TOKEN_SECRET>
|
||||
|
||||
# Keycloak OpenID Connect Configuration
|
||||
OVERLEAF_OIDC_ISSUER=https://sso.fb1.uni-bremen.de/sso/realms/master
|
||||
# (from your SSO realm / client registration)
|
||||
OVERLEAF_OIDC_CLIENT_ID=<OVERLEAF_OIDC_CLIENT_ID>
|
||||
OVERLEAF_OIDC_CLIENT_SECRET=<OVERLEAF_OIDC_CLIENT_SECRET>
|
||||
OVERLEAF_OIDC_IDENTITY_SERVICE_NAME='Log in with SSO FB1 Uni Bremen'
|
||||
|
||||
# eMail Account Configuration
|
||||
# (SMTP password for the user in OVERLEAF_EMAIL_SMTP_USER)
|
||||
OVERLEAF_EMAIL_PASSWORD=<OVERLEAF_EMAIL_PASSWORD>
|
||||
OVERLEAF_EMAIL_FROM_ADDRESS=overleaf@uni-bremen.de
|
||||
OVERLEAF_EMAIL_SMTP_HOST=smtp.uni-bremen.de
|
||||
OVERLEAF_EMAIL_SMTP_PORT=465
|
||||
OVERLEAF_EMAIL_SMTP_SECURE=true
|
||||
OVERLEAF_EMAIL_SMTP_USER=overleaf
|
||||
OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
|
||||
|
||||
# Other Overleaf Configurations Configuration
|
||||
OVERLEAF_SITE_URL=https://psintern.neuro.uni-bremen.de
|
||||
OVERLEAF_ADMIN_EMAIL=overleaf@uni-bremen.de
|
||||
OVERLEAF_APP_NAME="University of Bremen -- Overleaf"
|
||||
OVERLEAF_NAV_TITLE="University of Bremen -- Overleaf"
|
||||
OVERLEAF_CUSTOM_EMAIL_FOOTER="University of Bremen -- Overleaf"
|
||||
|
||||
##########
|
||||
# Wakapi #
|
||||
##########
|
||||
|
||||
# openssl rand -base64 30 | tr -dc "a-zA-Z0-9" | head -c 24; echo
|
||||
PG_PASSWORD=<PG_PASSWORD>
|
||||
|
||||
# HOST port for the wakapi web UI + API (3000 by convention; use 3001 if taken)
|
||||
WAKA_API_PORT=3000
|
||||
|
||||
|
||||
# image pins (bump + autoupdate on upgrade)
|
||||
WAKA_API_IMAGE=ghcr.io/muety/wakapi:2.18.2
|
||||
POSTGRES_IMAGE=postgres:18-alpine
|
||||
PROBE_IMAGE=busybox:1.36
|
||||
|
||||
# overleaf container IP on this network (WAKAPI_TRUST_REVERSE_PROXY_IPS)
|
||||
# — AUTO-MAINTAINED: up.sh / up_wakapi.sh rewrite this line from
|
||||
# `docker inspect overleaf` on every (re)start-adjacent run. Leave empty:
|
||||
OVERLEAF_IP=
|
||||
|
||||
# wakapi SMTP implicit TLS (smtp.uni-bremen.de:465)
|
||||
WAKAPI_MAIL_SMTP_TLS=true
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
docker exec -it git-bridge sh
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
docker exec -it mongo sh
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
docker exec -it nginx sh
|
||||
+1
@@ -0,0 +1 @@
|
||||
docker exec -it overleaf sh
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
docker exec -it redis sh
|
||||
@@ -0,0 +1 @@
|
||||
docker exec -it seaweedfs-filer sh
|
||||
@@ -0,0 +1 @@
|
||||
docker exec -it seaweedfs-master sh
|
||||
+1
@@ -0,0 +1 @@
|
||||
docker exec -it seaweedfs-s3 sh
|
||||
@@ -0,0 +1 @@
|
||||
docker exec -it seaweedfs-volume sh
|
||||
+1
@@ -0,0 +1 @@
|
||||
docker exec -it wakapi-pg sh
|
||||
+1
@@ -0,0 +1 @@
|
||||
docker exec -it wakapi-probe sh
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
docker exec -it wakapi sh
|
||||
@@ -0,0 +1 @@
|
||||
data
|
||||
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
git-bridge:
|
||||
restart: always
|
||||
image: quay.io/sharelatex/git-bridge:${GIT_BRIDE_VERSION}
|
||||
volumes:
|
||||
- ./data:/data/git-bridge
|
||||
container_name: git-bridge
|
||||
hostname: git-bridge
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
GIT_BRIDGE_API_BASE_URL: "http://overleaf:3000/api/v0/"
|
||||
GIT_BRIDGE_OAUTH2_SERVER: "http://overleaf"
|
||||
GIT_BRIDGE_POSTBACK_BASE_URL: "http://git-bridge:8000"
|
||||
GIT_BRIDGE_ROOT_DIR: "/data/git-bridge"
|
||||
user: root
|
||||
command: ["/server-pro-start.sh"]
|
||||
|
||||
healthcheck:
|
||||
# /status is git-bridge's liveness route (200 'ok'); the root path
|
||||
# needs an Overleaf session (500) and /healthz wants auth (401).
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:8000/status >/dev/null 2>&1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
networks:
|
||||
- overleaf-network
|
||||
|
||||
networks:
|
||||
overleaf-network:
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ngrams
|
||||
@@ -0,0 +1,31 @@
|
||||
services:
|
||||
languagetool:
|
||||
image: erikvl87/languagetool:latest
|
||||
container_name: languagetool
|
||||
hostname: languagetool
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
Java_Xms: 512m
|
||||
Java_Xmx: 2g
|
||||
langtool_languageModel: /ngrams
|
||||
|
||||
ports:
|
||||
- "127.0.0.1:32768:8010"
|
||||
|
||||
volumes:
|
||||
- ./ngrams:/ngrams:ro
|
||||
|
||||
networks:
|
||||
- overleaf-network
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --spider --timeout=5 http://localhost:8010/v2/languages 2>&1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
networks:
|
||||
overleaf-network:
|
||||
external: true
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download & extract LanguageTool n-gram data (main set: de en es fr nl)
|
||||
# into ngrams/ next to this script.
|
||||
#
|
||||
# Sources: https://languagetool.org/download/ngram-data/
|
||||
# ngrams-de-20150819.zip (~1.7 GB)
|
||||
# ngrams-en-20150817.zip (~9.0 GB)
|
||||
# ngrams-es-20150915.zip (~1.7 GB)
|
||||
# ngrams-fr-20150913.zip (~1.8 GB)
|
||||
# ngrams-nl-20181229.zip (~1.2 GB)
|
||||
#
|
||||
# Usage: ./download_ngrams.sh
|
||||
# Re-running is safe: already-downloaded & valid zips are skipped (download is
|
||||
# resumable via curl -C).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NGRAMS_DIR="$BASE/ngrams"
|
||||
BASE_URL="https://languagetool.org/download/ngram-data"
|
||||
|
||||
# lang -> zip filename (verified against server directory listing)
|
||||
declare -a FILES=(
|
||||
"ngrams-de-20150819.zip"
|
||||
"ngrams-en-20150817.zip"
|
||||
"ngrams-es-20150915.zip"
|
||||
"ngrams-fr-20150913.zip"
|
||||
"ngrams-nl-20181229.zip"
|
||||
)
|
||||
|
||||
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 1; }
|
||||
command -v unzip >/dev/null || { echo "ERROR: unzip not found" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$NGRAMS_DIR"
|
||||
echo "Target dir: $NGRAMS_DIR"
|
||||
|
||||
failed=0
|
||||
for zip in "${FILES[@]}"; do
|
||||
url="$BASE_URL/$zip"
|
||||
dest="$NGRAMS_DIR/$zip"
|
||||
|
||||
echo "=================================================================="
|
||||
echo "$zip"
|
||||
|
||||
if [ -f "$dest" ] && unzip -t "$dest" >/dev/null 2>&1; then
|
||||
echo " [skip] already downloaded and valid: $zip"
|
||||
else
|
||||
echo " [dl] $url"
|
||||
if ! curl --fail --location --retry 3 --retry-delay 5 -C - \
|
||||
--connect-timeout 30 --progress-bar -o "$dest" "$url"; then
|
||||
echo " [FAIL] download failed for $url (partial file kept for resume: $dest)" >&2
|
||||
rm -f "$dest" # 404/error body would corrupt the name for future runs
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " [extract] -> $NGRAMS_DIR"
|
||||
if ! unzip -o -q "$dest" -d "$NGRAMS_DIR"; then
|
||||
echo " [FAIL] extraction failed for $zip" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=================================================================="
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "Finished with ERRORS — re-run to resume failed downloads." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All done. Contents:"
|
||||
ls -lh "$NGRAMS_DIR"
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download & extract LanguageTool n-gram data (UNTESTED set) into ngrams/untested/
|
||||
# next to this script.
|
||||
#
|
||||
# Sources: https://languagetool.org/download/ngram-data/untested/
|
||||
# ngram-he-20150916.zip (~225 MB)
|
||||
# ngram-it-20150915.zip (~1.1 GB)
|
||||
# ngram-ru-20150914.zip (~2.3 GB)
|
||||
# ngram-zh-20150916.zip (~1.7 GB)
|
||||
#
|
||||
# NOTE: "an" (Aragonese) was requested but does NOT exist on the server
|
||||
# (checked ngram-data/, untested/, temp/, archive/ — only he/it/ru/zh are
|
||||
# offered). Kept commented out so it can be enabled if it appears later.
|
||||
#
|
||||
# Usage: ./download_ngrams_untested.sh
|
||||
# Re-running is safe: already-downloaded & valid zips are skipped (download is
|
||||
# resumable via curl -C).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NGRAMS_DIR="$BASE/ngrams"
|
||||
UNTESTED_DIR="$NGRAMS_DIR"
|
||||
BASE_URL="https://languagetool.org/download/ngram-data/untested"
|
||||
|
||||
# zip filenames (verified against server directory listing)
|
||||
declare -a FILES=(
|
||||
"ngram-he-20150916.zip"
|
||||
"ngram-it-20150915.zip"
|
||||
"ngram-ru-20150914.zip"
|
||||
"ngram-zh-20150916.zip"
|
||||
# "ngram-an-*.zip (404 — not available on the server as of 2025/2026)"
|
||||
)
|
||||
|
||||
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 1; }
|
||||
command -v unzip >/dev/null || { echo "ERROR: unzip not found" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$UNTESTED_DIR"
|
||||
echo "Target dir: $UNTESTED_DIR"
|
||||
|
||||
failed=0
|
||||
for zip in "${FILES[@]}"; do
|
||||
url="$BASE_URL/$zip"
|
||||
dest="$UNTESTED_DIR/$zip"
|
||||
|
||||
echo "=================================================================="
|
||||
echo "$zip"
|
||||
|
||||
if [ -f "$dest" ] && unzip -t "$dest" >/dev/null 2>&1; then
|
||||
echo " [skip] already downloaded and valid: $zip"
|
||||
else
|
||||
echo " [dl] $url"
|
||||
if ! curl --fail --location --retry 3 --retry-delay 5 -C - \
|
||||
--connect-timeout 30 --progress-bar -o "$dest" "$url"; then
|
||||
echo " [FAIL] download failed for $url (partial file kept for resume: $dest)" >&2
|
||||
rm -f "$dest" # 404/error body would corrupt the name for future runs
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " [extract] -> $UNTESTED_DIR"
|
||||
if ! unzip -o -q "$dest" -d "$UNTESTED_DIR"; then
|
||||
echo " [FAIL] extraction failed for $zip" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=================================================================="
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "Finished with ERRORS — re-run to resume failed downloads." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All done. Contents:"
|
||||
ls -lh "$UNTESTED_DIR"
|
||||
@@ -0,0 +1,102 @@
|
||||
% LanguageTool level test — ext-ce-v6.3.1 + erikvl87/languagetool
|
||||
%
|
||||
% Section A: expected to be flagged ONLY in Picky mode (level=picky).
|
||||
% Section B: expected to be flagged in BOTH modes (default and picky).
|
||||
%
|
||||
% All sentences below were verified live against the running server
|
||||
% (2026-10-26), with the exact disabledRules the Overleaf proxy sends.
|
||||
%
|
||||
% Usage: open in Overleaf, wait ~2 s after the last edit,
|
||||
% hover a wavy underline for the message + one-click replacements.
|
||||
% Toggle Settings → Spelling and Language → LanguageTool → "Picky mode"
|
||||
% off/on and compare: section A marks should appear/disappear.
|
||||
|
||||
\documentclass{article}
|
||||
\begin{document}
|
||||
|
||||
\section*{A: Picky-only (missed at default level)}
|
||||
|
||||
% PASSIVE_VOICE_SIMPLE — passive voice
|
||||
The experiment was conducted by our group.
|
||||
|
||||
% PASSIVE_VOICE_SIMPLE — passive voice
|
||||
The error was detected by the test suite.
|
||||
|
||||
\section*{B: Both modes (default and picky)}
|
||||
|
||||
% EN_A_VS_AN — wrong article
|
||||
An sequence off independent variables.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
You can recieve a bonus for this.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
This is a realy interesting result.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
He shoulda told us about the crash.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
The results shows a significant difference.
|
||||
|
||||
% BEEN_PART_AGREEMENT — possible agreement error
|
||||
A new paper was publish last week.
|
||||
|
||||
% THERE_S_MANY — possible agreement error
|
||||
There is three reasons for this choice.
|
||||
|
||||
% IT_IS — possible typo (missing apostrophe)
|
||||
Its time to finish the manuscript.
|
||||
|
||||
% AFFECT_EFFECT — commonly confused word
|
||||
This should have an affect on the outcome.
|
||||
|
||||
% LOOSE_LOSE — possible typo
|
||||
You will loose the key if you leave it here.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
You should of checked the configuration.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
It might of happened last night.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
We could of fixed the bug yesterday.
|
||||
|
||||
% INFORMATIONS — nonstandard word
|
||||
We need more informations about this topic.
|
||||
|
||||
% EN_A_VS_AN — wrong article (uncountable)
|
||||
He gave me a advice yesterday morning.
|
||||
|
||||
% ENGLISH_WORD_REPEAT_RULE — word repetition
|
||||
The the method was tested twice.
|
||||
|
||||
% ADMIT_ENJOY_VB — use 'ing'
|
||||
I look forward to hear from you again.
|
||||
|
||||
% TOO_TO — possible typo
|
||||
I want to too participate in this.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
Each of the students have passed the exam.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
The dog which barks is friendly.
|
||||
|
||||
% RB_RB_COMMA — intensifier comma
|
||||
We have a very very good result here.
|
||||
|
||||
% FREE_GIFT — redundant phrase
|
||||
This is a free gift for all of you.
|
||||
|
||||
% ADMIT_ENJOY_VB — use 'ing'
|
||||
The manual recommends to restart the server.
|
||||
|
||||
% ALLOW_TO — use a gerund
|
||||
The tool allows to configure the output.
|
||||
|
||||
% ALLOW_TO — use a gerund
|
||||
The server requires to restart after the change.
|
||||
|
||||
\end{document}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f git-bridge
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f languagetool
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f mongo
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f nginx
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f overleaf
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f redis
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f seaweedfs-filer
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f seaweedfs-master
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f seaweedfs-s3
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f seaweedfs-volume
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f wakapi-pg
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f wakapi-probe
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f wakapi
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
backup
|
||||
data_db
|
||||
+1
@@ -0,0 +1 @@
|
||||
docker exec mongo bash -c "mongodump --out /backup/"
|
||||
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:${MONGO_VERSION}
|
||||
container_name: mongo
|
||||
hostname: mongo
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: "mongosh --quiet --eval 'rs.hello().setName ? rs.hello().setName : rs.initiate({_id: \"overleaf\",members:[{_id: 0, host:\"mongo:27017\"}]})'"
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
command: "--replSet overleaf"
|
||||
environment:
|
||||
GLIBC_TUNABLES: glibc.cpu.hwcaps=-SHSTK
|
||||
volumes:
|
||||
- ./data_db:/data/db
|
||||
- ./backup:/backup
|
||||
|
||||
networks:
|
||||
- overleaf-network
|
||||
extra_hosts:
|
||||
- "mongo:127.0.0.1"
|
||||
|
||||
networks:
|
||||
overleaf-network:
|
||||
external: true
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:stable-alpine
|
||||
container_name: nginx
|
||||
hostname: nginx
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
- ${SSL_KEY_FILE}:/certs/nginx_key.pem:ro
|
||||
- ${SSL_CERT_FILE}:/certs/nginx_certificate.pem:ro
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- /var/www/acme:/var/www/acme:ro
|
||||
- ./static:/var/www/static
|
||||
|
||||
ports:
|
||||
- "0.0.0.0:443:443"
|
||||
- "0.0.0.0:80:80"
|
||||
- ":::443:443"
|
||||
- ":::80:80"
|
||||
|
||||
environment:
|
||||
NGINX_WORKER_PROCESSES: "4"
|
||||
NGINX_WORKER_CONNECTIONS: "768"
|
||||
|
||||
networks:
|
||||
- overleaf-network
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsI --connect-timeout 10 http://localhost || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
networks:
|
||||
overleaf-network:
|
||||
external: true
|
||||
@@ -0,0 +1,71 @@
|
||||
events {}
|
||||
http {
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
# ACME challenge — must come before the redirect
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/acme;
|
||||
}
|
||||
# Everything else redirects to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /certs/nginx_certificate.pem;
|
||||
ssl_certificate_key /certs/nginx_key.pem;
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains;";
|
||||
server_tokens off;
|
||||
client_max_body_size 50M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://overleaf:80;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 3m;
|
||||
proxy_send_timeout 3m;
|
||||
proxy_hide_header Cache-Control;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
location /ub/ {
|
||||
alias /var/www/static/ub/;
|
||||
try_files $uri =404;
|
||||
types {
|
||||
image/svg+xml svg;
|
||||
}
|
||||
}
|
||||
|
||||
# Bundled Wakapi (WakaTime-compatible server) — UI + API under /wakapi.
|
||||
# Wakapi 2.18.2 contract: the app serves its routes at / and uses
|
||||
# WAKAPI_BASE_PATH=/wakapi only for the URLs/cookies it generates —
|
||||
# so the proxy must STRIP the prefix (trailing slash on proxy_pass):
|
||||
# /wakapi/login → /login, while its links stay /wakapi/… end-to-end.
|
||||
# Security: strip any client-supplied identity header — only the overleaf
|
||||
# container (pinned source IP) may use the trusted-header identity.
|
||||
# /wakapi (exact) → canonical form with trailing slash
|
||||
location = /wakapi {
|
||||
return 301 /wakapi/;
|
||||
}
|
||||
location /wakapi/ {
|
||||
proxy_pass http://wakapi:3000/;
|
||||
proxy_set_header Remote-User "";
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 39.761 39.761" width="32" height="32"><path d="M8.184 15.336c0 3.648 3.035 6.551 6.679 6.551 3.649 0 6.684-2.836 6.684-6.551v-13.5h-3.309v13.5c0 1.824-1.484 3.242-3.304 3.242-1.758 0-3.309-1.418-3.309-3.242v-13.5H8.184Z" style="fill:#d7193a;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="scale(1.33333)"/><path d="M24.789 1.836v13.5c0 5.469-4.523 9.855-9.992 9.855-5.535 0-9.988-4.32-9.988-9.855v-13.5H1.5v13.5C1.5 22.695 7.508 28.5 14.797 28.5c7.359 0 13.297-5.805 13.297-13.164v-13.5z" style="fill:#d7193a;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="scale(1.33333)"/></svg>
|
||||
|
After Width: | Height: | Size: 672 B |
@@ -0,0 +1,2 @@
|
||||
data
|
||||
log
|
||||
@@ -0,0 +1,216 @@
|
||||
services:
|
||||
overleaf:
|
||||
image: docker.io/sharelatex/sharelatex:${OVERLEAF_VERSION}
|
||||
container_name: overleaf
|
||||
hostname: overleaf
|
||||
restart: always
|
||||
volumes:
|
||||
- ./data:/var/lib/overleaf
|
||||
- ./log:/var/log/overleaf
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
environment:
|
||||
GIT_BRIDGE_ENABLED: true
|
||||
GIT_BRIDGE_HOST: git-bridge
|
||||
GIT_BRIDGE_PORT: 8000
|
||||
|
||||
## LanguageTool grammar check (services/web/modules/languagetool)
|
||||
## Requires the `languagetool` container reachable on the attached
|
||||
## `languagetools` network (see tools/languagetools_server_compose).
|
||||
## Picky level is the default on the client side.
|
||||
LANGUAGE_TOOL_ENABLED: true
|
||||
LANGUAGE_TOOL_HOST: languagetool
|
||||
LANGUAGE_TOOL_PORT: "8010"
|
||||
|
||||
## WakaTime/Wakapi activity tracking (PR 249 + bundled Wakapi server)
|
||||
## — see tools/wakapi_server_compose in the overleaf-cep repo).
|
||||
## Shared identity (trusted header, muety/wakapi#808): NO per-user
|
||||
## registration, NO API keys — logged-in users appear in Wakapi
|
||||
## automatically. The 'wakapi' DNS alias + this env must both be in
|
||||
## place; re-run tools/wakapi_server_compose/1_attach_overleaf_network.sh
|
||||
## after every overleaf container recreation.
|
||||
WAKATIME_SHARED_IDENTITY: true
|
||||
WAKATIME_SHARED_IDENTITY_API_URL: http://wakapi:3000/api/compat/wakatime/v1
|
||||
WAKATIME_SHARED_IDENTITY_HEADER: Remote-User
|
||||
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
OVERLEAF_REDIS_HOST: redis
|
||||
|
||||
V1_HISTORY_URL: http://overleaf:3100/api
|
||||
|
||||
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex
|
||||
|
||||
OVERLEAF_APP_NAME: ${OVERLEAF_APP_NAME}
|
||||
|
||||
ENABLED_LINKED_FILE_TYPES: project_file,project_output_file,url,zotero
|
||||
ENABLE_CONVERSIONS: true
|
||||
EMAIL_CONFIRMATION_DISABLED: true
|
||||
OVERLEAF_BEHIND_PROXY: true
|
||||
OVERLEAF_SECURE_COOKIE: true
|
||||
OVERLEAF_SITE_URL: ${OVERLEAF_SITE_URL}
|
||||
OVERLEAF_NAV_TITLE: ${OVERLEAF_NAV_TITLE}
|
||||
NAV_HIDE_POWERED_BY: true
|
||||
OVERLEAF_ADMIN_EMAIL: ${OVERLEAF_ADMIN_EMAIL}
|
||||
OVERLEAF_LOGIN_SUPPORT_TEXT: ${OVERLEAF_LOGIN_SUPPORT_TEXT}
|
||||
SHOW_UNSUPPORT_WARNING: false
|
||||
ENABLE_LOCAL_LOGIN_MASK: false
|
||||
|
||||
########################
|
||||
## eMail ##
|
||||
########################
|
||||
|
||||
OVERLEAF_EMAIL_FROM_ADDRESS: ${OVERLEAF_EMAIL_FROM_ADDRESS}
|
||||
OVERLEAF_EMAIL_SMTP_HOST: ${OVERLEAF_EMAIL_SMTP_HOST}
|
||||
OVERLEAF_EMAIL_SMTP_PORT: ${OVERLEAF_EMAIL_SMTP_PORT}
|
||||
OVERLEAF_EMAIL_SMTP_SECURE: ${OVERLEAF_EMAIL_SMTP_SECURE}
|
||||
OVERLEAF_EMAIL_SMTP_USER: ${OVERLEAF_EMAIL_SMTP_USER}
|
||||
OVERLEAF_EMAIL_SMTP_PASS: ${OVERLEAF_EMAIL_PASSWORD}
|
||||
OVERLEAF_EMAIL_SMTP_IGNORE_TLS: ${OVERLEAF_EMAIL_SMTP_IGNORE_TLS}
|
||||
OVERLEAF_EMAIL_SMTP_LOGGER: true
|
||||
OVERLEAF_CUSTOM_EMAIL_FOOTER: ${OVERLEAF_CUSTOM_EMAIL_FOOTER}
|
||||
|
||||
#########################################
|
||||
## Overleaf Filestore S3 Configuration ##
|
||||
#########################################
|
||||
|
||||
OVERLEAF_FILESTORE_BACKEND: s3
|
||||
OVERLEAF_FILESTORE_USER_FILES_BUCKET_NAME: overleaf-user-files
|
||||
OVERLEAF_FILESTORE_TEMPLATE_FILES_BUCKET_NAME: overleaf-template-files
|
||||
|
||||
OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||
OVERLEAF_FILESTORE_S3_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||
OVERLEAF_FILESTORE_S3_ENDPOINT: http://s3:8333
|
||||
OVERLEAF_FILESTORE_S3_PATH_STYLE: "true"
|
||||
OVERLEAF_FILESTORE_S3_REGION: "us-east-1"
|
||||
|
||||
#########################################
|
||||
## Overleaf History S3 Configuration ##
|
||||
#########################################
|
||||
|
||||
OVERLEAF_HISTORY_BACKEND: s3
|
||||
OVERLEAF_HISTORY_PROJECT_BLOBS_BUCKET: overleaf-project-blobs
|
||||
OVERLEAF_HISTORY_CHUNKS_BUCKET: overleaf-chunks
|
||||
|
||||
OVERLEAF_HISTORY_S3_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||
OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||
OVERLEAF_HISTORY_S3_ENDPOINT: http://s3:8333
|
||||
OVERLEAF_HISTORY_S3_PATH_STYLE: "true"
|
||||
OVERLEAF_HISTORY_S3_REGION: "us-east-1"
|
||||
|
||||
########################
|
||||
## OIDC ##
|
||||
########################
|
||||
|
||||
EXTERNAL_AUTH: oidc
|
||||
OVERLEAF_OIDC_PROVIDER_ID: oidc
|
||||
OVERLEAF_OIDC_ISSUER: ${OVERLEAF_OIDC_ISSUER}
|
||||
OVERLEAF_OIDC_AUTHORIZATION_URL: ${OVERLEAF_OIDC_ISSUER}/protocol/openid-connect/auth
|
||||
OVERLEAF_OIDC_TOKEN_URL: ${OVERLEAF_OIDC_ISSUER}/protocol/openid-connect/token
|
||||
OVERLEAF_OIDC_USER_INFO_URL: ${OVERLEAF_OIDC_ISSUER}/protocol/openid-connect/userinfo
|
||||
OVERLEAF_OIDC_LOGOUT_URL: ${OVERLEAF_OIDC_ISSUER}/protocol/openid-connect/logout
|
||||
OVERLEAF_OIDC_IDENTITY_SERVICE_NAME: ${OVERLEAF_OIDC_IDENTITY_SERVICE_NAME}
|
||||
OVERLEAF_OIDC_CLIENT_ID: ${OVERLEAF_OIDC_CLIENT_ID}
|
||||
OVERLEAF_OIDC_CLIENT_SECRET: ${OVERLEAF_OIDC_CLIENT_SECRET}
|
||||
OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN: true
|
||||
OVERLEAF_OIDC_IS_ADMIN_FIELD: email
|
||||
OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE: ${OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE}
|
||||
|
||||
OVERLEAF_LEFT_FOOTER: ${OVERLEAF_LEFT_FOOTER}
|
||||
OVERLEAF_RIGHT_FOOTER: '[{"text":"Powered by Overleaf CEP", "url":"https://github.com/yu-i-i/overleaf-cep"}]'
|
||||
|
||||
########################
|
||||
## Sandboxed Compiles ##
|
||||
########################
|
||||
|
||||
SERVER_PRO: true
|
||||
DOCKER_RUNNER: true
|
||||
|
||||
SIBLING_CONTAINERS_ENABLED: true
|
||||
DOCKER_SOCKET_PATH: /var/run/docker.sock
|
||||
|
||||
SANDBOXED_COMPILES: true
|
||||
SANDBOXED_COMPILES_SIBLING_CONTAINERS: true
|
||||
|
||||
# WARNING: CHANGE DIR!!!!
|
||||
SANDBOXED_COMPILES_HOST_DIR: ${COMPILES_HOST_DIR}
|
||||
COMPILES_HOST_DIR: ${COMPILES_HOST_DIR}
|
||||
# png2pdf: CLSI's per-project cache dir must be host-visible so the
|
||||
# png2pdf sibling container can bind-mount it (CLSI_CACHE_PATH inside
|
||||
# the container == SANDBOXED_COMPILES_HOST_DIR_CACHE on the host,
|
||||
# via the ./data -> /var/lib/overleaf mount)
|
||||
CLSI_CACHE_PATH: /var/lib/overleaf/data/cache
|
||||
SANDBOXED_COMPILES_HOST_DIR_CACHE: ${COMPILES_HOST_DIR_CACHE}
|
||||
PNG2PDF_MIN_FILE_SIZE_BYTES: 50000
|
||||
|
||||
# filestore PDF→image conversion backend (template gallery preview/thumb).
|
||||
# Choices:
|
||||
# imagemagick local `convert` (image must ship ImageMagick)
|
||||
# pdftocairo local `pdftocairo` (image must ship poppler)
|
||||
# pdftocairo-sandbox sibling container (no network, CapDrop ALL, mem cap) —
|
||||
# works on images WITHOUT any converter binaries
|
||||
CONVERTER: pdftocairo-sandbox
|
||||
CONVERSION_SANDBOX_IMAGE: sharelatex/pdftocairo:24.02
|
||||
# Scratch dir bound into the sandbox container — the same directory seen from
|
||||
# two sides (the overleaf container mounts ./data):
|
||||
CONVERSION_SANDBOX_DIR: /var/lib/overleaf/tmp/conversions
|
||||
CONVERSION_SANDBOX_HOST_DIR: ${CONVERSION_SANDBOX_HOST_DIR}
|
||||
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES: ${TEX_LIVE_REPO}/texlive-full:2026.1, ${TEX_LIVE_REPO}/texlive-full:2025.1, ${TEX_LIVE_REPO}/texlive-full:2024.1, ${TEX_LIVE_REPO}/texlive-full:2023.1
|
||||
ALL_TEX_LIVE_DOCKER_IMAGE_NAMES: TeXLive 2026, TeXLive 2025, TeXLive 2024, TeXLive 2023
|
||||
TEX_LIVE_DOCKER_IMAGE: ${TEX_LIVE_REPO}/texlive-full:2026.1
|
||||
TEX_COMPILER_EXTRA_FLAGS: -shell-escape
|
||||
TEXLIVE_IMAGE_USER: www-data
|
||||
COMPILE_TIMEOUT: 599
|
||||
|
||||
#################
|
||||
# TEMPLATES #
|
||||
#################
|
||||
OVERLEAF_TEMPLATE_GALLERY: true
|
||||
OVERLEAF_NON_ADMIN_CAN_PUBLISH_TEMPLATES: false
|
||||
OVERLEAF_TEMPLATE_CATEGORIES: presentation thesis
|
||||
|
||||
TEMPLATE_PRESENTATION_NAME: Presentations
|
||||
TEMPLATE_PRESENTATION_DESCRIPTION: Templates for Beamer and other presentation formats, tailored for academic talks and lectures.
|
||||
|
||||
TEMPLATE_THESIS_NAME: Theses
|
||||
TEMPLATE_THESIS_DESCRIPTION: Templates for writing theses and dissertations, following institutional formatting and citation guidelines.
|
||||
|
||||
TEMPLATE_ALL_NAME: All templates
|
||||
TEMPLATE_ALL_DESCRIPTION: Browse a collection of all available LaTeX templates, categorized by document type, style, and purpose.
|
||||
|
||||
####################
|
||||
# Special settings #
|
||||
####################
|
||||
COMPILE_BODY_SIZE_LIMIT_MB: 50
|
||||
COMPILE_SIZE_LIMIT: 50mb
|
||||
|
||||
ENABLE_PANDOC_CONVERSIONS: true
|
||||
PANDOC_IMAGE: sharelatex/sharelatex-pandoc:${OVERLEAF_VERSION}
|
||||
|
||||
ENABLE_PNG2PDF_CONVERSIONS: true
|
||||
PNG2PDF_IMAGE: sharelatex/png2pdf:2026-06-24
|
||||
|
||||
ZOTERO_CLIENT_KEY: ${ZOTERO_CLIENT_KEY}
|
||||
ZOTERO_CLIENT_SECRET: ${ZOTERO_CLIENT_SECRET}
|
||||
|
||||
GITHUB_SYNC_ENABLED: true
|
||||
GITHUB_SYNC_CLIENT_ID: ${GITHUB_SYNC_CLIENT_ID}
|
||||
GITHUB_SYNC_CLIENT_SECRET: ${GITHUB_SYNC_CLIENT_SECRET}
|
||||
|
||||
OVERLEAF_INVITE_TOKEN_SECRET: ${OVERLEAF_INVITE_TOKEN_SECRET}
|
||||
|
||||
networks:
|
||||
- overleaf-network
|
||||
|
||||
healthcheck:
|
||||
test: bash -c "curl -fsI --connect-timeout 10 http://localhost || exit 1"
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 600s
|
||||
|
||||
networks:
|
||||
overleaf-network:
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
data
|
||||
backup
|
||||
+1
@@ -0,0 +1 @@
|
||||
docker exec redis sh -c "redis-cli save && cp -f /data/dump.rdb /backup"
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
redis:
|
||||
image: redis:${REDIS_VERSION}
|
||||
container_name: redis
|
||||
hostname: redis
|
||||
restart: always
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
|
||||
start_period: 20s
|
||||
interval: 30s
|
||||
retries: 5
|
||||
timeout: 3s
|
||||
|
||||
command: --save 60 1 --loglevel warning
|
||||
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./backup:/backup
|
||||
|
||||
networks:
|
||||
- overleaf-network
|
||||
|
||||
environment:
|
||||
REDIS_AOF_PERSISTENCE: "true"
|
||||
|
||||
networks:
|
||||
overleaf-network:
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
data
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
seaweedfs-filer:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: seaweedfs-filer
|
||||
hostname: seaweedfs-filer
|
||||
ports:
|
||||
- "127.0.0.1:8888:8888" # filer HTTP
|
||||
- "127.0.0.1:18888:18888" # filer gRPC
|
||||
command: 'filer -master="master:9333" -ip=filer -ip.bind=0.0.0.0'
|
||||
volumes:
|
||||
- ./data:/data
|
||||
networks:
|
||||
overleaf-network:
|
||||
aliases:
|
||||
- filer
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8888/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
networks:
|
||||
overleaf-network:
|
||||
external: true
|
||||
@@ -0,0 +1 @@
|
||||
data
|
||||
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
seaweedfs-master:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: seaweedfs-master
|
||||
hostname: seaweedfs-master
|
||||
ports:
|
||||
- "127.0.0.1:9333:9333" # master HTTP
|
||||
- "127.0.0.1:19333:19333" # master gRPC
|
||||
command: "master -ip=master -ip.bind=0.0.0.0 -metricsPort=9327"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
networks:
|
||||
overleaf-network:
|
||||
# classic seaweedfs names (master:9333 etc.) must resolve on the shared
|
||||
# network while the container keeps its seaweedfs-* name.
|
||||
aliases:
|
||||
- master
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9333/cluster/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
overleaf-network:
|
||||
external: true
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user