mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-18 00:57:55 +00:00
* feat(colors): clamp author backgrounds to WCAG 2.1 AA on render
Fixes #7377.
Authors can pick any color via the color picker, so a user who chooses
a dark red ends up with black text rendered on a background that fails
WCAG 2.1 AA (4.5:1) — unreadable, but there is no way for *viewers* to
remediate since they cannot change another author's color. Screenshot
in the issue shows exactly this.
This PR lands a viewer-side clamp. For each author background, if
neither black nor white text would satisfy the target contrast ratio,
the bg is iteratively blended toward white until black text does. The
author's stored color is untouched — turning off the new
padOptions.enforceReadableAuthorColors flag restores the raw colors
immediately.
New helpers in src/static/js/colorutils.ts:
- relativeLuminance(triple) — WCAG 2.1 relative-luminance formula
- contrastRatio(c1, c2) — in [1, 21]; >=4.5 = AA, >=7.0 = AAA
- ensureReadableBackground(hex, minContrast = 4.5)
— returns a hex that meets minContrast
against black text, preserving hue
Wire-up:
- src/static/js/ace2_inner.ts (setAuthorStyle): pass bgcolor through
ensureReadableBackground before picking text color. Gated on
padOptions.enforceReadableAuthorColors (default true). Guarded by
colorutils.isCssHex so the few non-hex values (CSS vars, etc.) skip
the clamp and pass through unchanged.
- Settings.ts / settings.json.template / settings.json.docker: new
padOptions.enforceReadableAuthorColors flag, default true, with a
matching PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS env var in the
docker template.
- doc/docker.md: env-var row.
- src/tests/backend/specs/colorutils.ts: new unit coverage for the
three new helpers, including the exact #cc0000 failure case from
the issue screenshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(7377): simplify — just pick higher-contrast text, drop bg clamp
First iteration added an iterative bg-lightening helper
(ensureReadableBackground) gated by a new padOptions flag. CI caught the
correct simpler framing: because WCAG contrast is symmetric in [1, 21],
at least one of black/white always clears AA (4.5:1) for any sRGB
colour. The real bug was that the pre-fix textColorFromBackgroundColor
used a plain-luminosity cutoff (< 0.5 → white), which produced
sub-AA combinations like white-on-red (#ff0000) at 4.0:1.
Reduce the PR to the minimal surface:
- colorutils.textColorFromBackgroundColor now picks whichever of
black/white has the higher WCAG contrast ratio against the bg.
- colorutils.relativeLuminance and colorutils.contrastRatio are kept
as reusable building blocks; ensureReadableBackground is dropped
(no caller needed it once text selection was fixed).
- ace2_inner.ts setAuthorStyle no longer needs the opt-in flag or the
isCssHex guard — the helper handles every input its caller already
passes.
- padOptions.enforceReadableAuthorColors setting reverted along with
settings.json.template, settings.json.docker, and doc/docker.md.
- Tests replaced: instead of asserting the bg gets lightened, assert
that the chosen text colour clears AA for every primary. Covers the
exact #ff0000 failure case from the issue screenshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7377): assert relative-contrast invariant, not absolute AA
Pure primaries like #ff0000 cannot clear WCAG AA (4.5:1) against either
#222 or #fff — the best either can do is ~4.0:1. No text-colour choice
alone fixes that; bg clamping would be a separate concern. The test
should therefore verify the *real* invariant: the chosen text colour
must produce the higher contrast of the two options, regardless of
whether that contrast clears any absolute threshold.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7377): compare against rendered #222/#fff, not pure black/white
First cut of textColorFromBackgroundColor computed contrast against
pure black (L=0) and pure white (L=1), then returned the concrete
#222/#fff the pad actually renders with. For some mid-saturation
backgrounds the two comparisons disagreed — e.g. #ff0000:
vs pure black = 5.25 → pick black → render #222 → actual 3.98
vs pure white = 4.00 → would-render #fff → actual 4.00
The helper picked the wrong option because it compared against the
wrong target. Compare against the actual rendered colours so the
returned text colour is genuinely the higher-contrast choice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7377): pick unambiguous colibris test bgs
#ff0000 lives right at the boundary for the two text choices (4.00 vs
3.98), so the test for colibris-skin mapping was entangled with the
border-case selector pick. Use #ffeedd (clearly light → dark text
wins) and #111111 (clearly dark → light text wins) so the test
isolates the skin mapping from the tie-breaking logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7377): use rendered text colour + clamp bg to actually meet AA
Local repro of the issue exposed two real bugs in the previous fix:
1. textColorFromBackgroundColor compared bg against a hardcoded #222 —
but in the colibris skin --super-dark-color resolves to #485365.
For the issue's exact case (#9AB3FA author bg) the selector returned
var(--super-dark-color) thinking it was getting a 7.7:1 ratio, while
the browser actually rendered 3.78:1 — identical to what the issue
screenshot reported. This PR's previous behaviour on the issue's
inputs was unchanged from the pre-fix.
2. For mid-saturation pastels (#9AB3FA) and pure primaries (#ff0000)
neither rendered dark nor white text can clear AA. Text-colour
selection alone genuinely cannot fix this band; the ensureReadable
bg clamp dropped in ce0c5c283 was load-bearing.
Changes:
- colorutils.ts: per-skin SKIN_TEXT_COLORS table with darkRef/lightRef
matching what the browser actually paints (colibris #485365,
default #222). Re-introduces ensureReadableBackground, but skin-aware
and symmetric — blends bg toward white or black depending on which
text colour wins, so it works for both light and dark backgrounds.
- ace2_inner.ts: setAuthorStyle runs the bg through the clamp before
picking text colour. Gated on padOptions.enforceReadableAuthorColors
(default true).
- Settings.ts / settings.json.template / settings.json.docker /
doc/docker.md: padOption + PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS
env var.
- tests: failing-then-green coverage for the issue's exact case
(#9AB3FA + colibris), the previously-impossible #ff0000, the
no-mutation case, non-hex pass-through, and a sweep over primaries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7377): add e2e DOM-contrast spec + extra unit cases
The previous coverage was unit-only, which is what let the original wrong-
reference-colour bug ship — the algorithm tests were green but nothing
exercised what the browser actually paints. New coverage:
Playwright (src/tests/frontend-new/specs/wcag_author_color.spec.ts):
- Sets the user's colour to the issue's exact #9AB3FA, types text, reads
the rendered author span's computed bg + colour from the inner frame,
and asserts the WCAG ratio between the two is >= 4.5. Repeated for
#ff0000 (the other historically-failing case).
- Asserts #ffeedd (already AA-friendly) is rendered unchanged — guards
against the clamp mutating colours that don't need it.
Backend additions (src/tests/backend/specs/colorutils.ts):
- Symmetric-clamp test: dark mid-saturation bg where light text wins, the
clamp must darken (not lighten). Direction check via relativeLuminance.
- minContrast parameter: AAA (7.0) must produce more clamping than AA.
- Output shape: result must be a parseable hex string (round-trip safe).
- Short-hex (#abc) input is accepted and normalised.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1198 lines
40 KiB
TypeScript
1198 lines
40 KiB
TypeScript
'use strict';
|
|
/**
|
|
* The Settings module reads the settings out of settings.json and provides
|
|
* this information to the other modules
|
|
*
|
|
* TODO muxator 2020-04-14:
|
|
*
|
|
* 1) get rid of the reloadSettings() call at module loading;
|
|
* 2) provide a factory method that configures the settings module at runtime,
|
|
* reading the file name either from command line parameters, from a function
|
|
* argument, or falling back to a default.
|
|
*/
|
|
|
|
/*
|
|
* 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS-IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import {MapArrayType} from "../types/MapType";
|
|
import {SettingsNode} from "./SettingsTree";
|
|
|
|
import * as absolutePaths from './AbsolutePaths';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {argv} from './Cli'
|
|
import jsonminify from 'jsonminify';
|
|
import log4js from 'log4js';
|
|
import {createHash} from 'node:crypto';
|
|
import randomString from './randomstring';
|
|
const suppressDisableMsg = ' -- To suppress these warning messages change ' +
|
|
'suppressErrorsInPadText to true in your settings.json\n';
|
|
import _ from 'underscore';
|
|
|
|
const logger = log4js.getLogger('settings');
|
|
|
|
// Exported values that settings.json and credentials.json cannot override.
|
|
const nonSettings = [
|
|
'credentialsFilename',
|
|
'settingsFilename',
|
|
];
|
|
|
|
// This is a function to make it easy to create a new instance. It is important to not reuse a
|
|
// config object after passing it to log4js.configure() because that method mutates the object. :(
|
|
const defaultLogConfig = (level: string, layoutType: string) => ({
|
|
appenders: {console: {type: 'console', layout: {type: layoutType}}},
|
|
categories: {
|
|
default: {appenders: ['console'], level},
|
|
}
|
|
});
|
|
const defaultLogLevel = 'INFO';
|
|
const defaultLogLayoutType = 'colored';
|
|
|
|
const initLogging = (config: any) => {
|
|
// log4js.configure() modifies settings.logconfig so check for equality first.
|
|
log4js.configure(config);
|
|
log4js.getLogger('console');
|
|
|
|
// Overwrites for console output methods
|
|
console.debug = logger.debug.bind(logger);
|
|
console.log = logger.info.bind(logger);
|
|
console.warn = logger.warn.bind(logger);
|
|
console.error = logger.error.bind(logger);
|
|
};
|
|
|
|
// Initialize logging as early as possible with reasonable defaults. Logging will be re-initialized
|
|
// with the user's chosen log level and logger config after the settings have been loaded.
|
|
initLogging(defaultLogConfig(defaultLogLevel, defaultLogLayoutType));
|
|
|
|
// Parse func
|
|
|
|
|
|
|
|
/**
|
|
* - reads the JSON configuration file settingsFilename from disk
|
|
* - strips the comments
|
|
* - replaces environment variables calling lookupEnvironmentVariables()
|
|
* - returns a parsed Javascript object
|
|
*
|
|
* The isSettings variable only controls the error logging.
|
|
*/
|
|
const parseSettings = (settingsFilename: string, isSettings: boolean) => {
|
|
let settingsStr = '';
|
|
|
|
let settingsType, notFoundMessage, notFoundFunction;
|
|
|
|
if (isSettings) {
|
|
settingsType = 'settings';
|
|
notFoundMessage = 'Continuing using defaults!';
|
|
notFoundFunction = logger.warn.bind(logger);
|
|
} else {
|
|
settingsType = 'credentials';
|
|
notFoundMessage = 'Ignoring.';
|
|
notFoundFunction = logger.info.bind(logger);
|
|
}
|
|
|
|
try {
|
|
// read the settings file
|
|
settingsStr = fs.readFileSync(settingsFilename).toString();
|
|
} catch (e) {
|
|
notFoundFunction(`No ${settingsType} file found in ${settingsFilename}. ${notFoundMessage}`);
|
|
|
|
// or maybe undefined!
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}');
|
|
|
|
const settings = JSON.parse(settingsStr);
|
|
|
|
logger.info(`${settingsType} loaded from: ${settingsFilename}`);
|
|
|
|
return lookupEnvironmentVariables(settings);
|
|
} catch (e: any) {
|
|
logger.error(`There was an error processing your ${settingsType} ` +
|
|
`file from ${settingsFilename}: ${e.message}`);
|
|
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
|
|
// Provide git version if available
|
|
export const getGitCommit = () => {
|
|
let version = '';
|
|
try {
|
|
let rootPath = absolutePaths.findEtherpadRoot();
|
|
if (fs.lstatSync(`${rootPath}/.git`).isFile()) {
|
|
rootPath = fs.readFileSync(`${rootPath}/.git`, 'utf8');
|
|
rootPath = rootPath.split(' ').pop()?.trim() ?? '';
|
|
} else {
|
|
rootPath += '/.git';
|
|
}
|
|
const ref = fs.readFileSync(`${rootPath}/HEAD`, 'utf-8');
|
|
if (ref.startsWith('ref: ')) {
|
|
const refPath = `${rootPath}/${ref.substring(5, ref.indexOf('\n'))}`;
|
|
version = fs.readFileSync(refPath, 'utf-8');
|
|
} else {
|
|
version = ref;
|
|
}
|
|
version = version.substring(0, 7);
|
|
} catch (e: any) {
|
|
logger.warn(`Can't get git version for server header\n${e.message}`);
|
|
}
|
|
return version;
|
|
};
|
|
|
|
export type SettingsType = {
|
|
root: string,
|
|
settingsFilename: string,
|
|
credentialsFilename: string,
|
|
title: string,
|
|
showRecentPads: boolean,
|
|
favicon: string | null,
|
|
publicURL: string | null,
|
|
ttl: {
|
|
AccessToken: number,
|
|
AuthorizationCode: number,
|
|
ClientCredentials: number,
|
|
IdToken: number,
|
|
RefreshToken: number,
|
|
},
|
|
updateServer: string,
|
|
enableDarkMode: boolean,
|
|
enablePadWideSettings: boolean,
|
|
allowPadDeletionByAllUsers: boolean,
|
|
skinName: string | null,
|
|
skinVariants: string,
|
|
ip: string,
|
|
port: number | string,
|
|
suppressErrorsInPadText: boolean,
|
|
ssl: false | {
|
|
key: string,
|
|
cert: string,
|
|
ca: string | null,
|
|
},
|
|
socketTransportProtocols: any[],
|
|
socketIo: {
|
|
maxHttpBufferSize: number,
|
|
},
|
|
authenticationMethod: string,
|
|
dbType: string,
|
|
dbSettings: any,
|
|
defaultPadText: string,
|
|
padOptions: {
|
|
noColors: boolean,
|
|
showControls: boolean,
|
|
showChat: boolean,
|
|
showLineNumbers: boolean,
|
|
useMonospaceFont: boolean,
|
|
userName: string | null,
|
|
userColor: string | null,
|
|
rtl: boolean,
|
|
alwaysShowChat: boolean,
|
|
chatAndUsers: boolean,
|
|
lang: string | null,
|
|
fadeInactiveAuthorColors: boolean,
|
|
enforceReadableAuthorColors: boolean,
|
|
},
|
|
enableMetrics: boolean,
|
|
padShortcutEnabled: {
|
|
altF9: boolean,
|
|
altC: boolean,
|
|
delete: boolean,
|
|
cmdShift2: boolean,
|
|
return: boolean,
|
|
esc: boolean,
|
|
cmdS: boolean,
|
|
tab: boolean,
|
|
cmdZ: boolean,
|
|
cmdY: boolean,
|
|
cmdB: boolean,
|
|
cmdI: boolean,
|
|
cmdU: boolean,
|
|
cmd5: boolean,
|
|
cmdShiftL: boolean,
|
|
cmdShiftN: boolean,
|
|
cmdShift1: boolean,
|
|
cmdShiftC: boolean,
|
|
cmdShiftD: boolean,
|
|
cmdShiftK: boolean,
|
|
cmdH: boolean,
|
|
ctrlHome: boolean,
|
|
pageUp: boolean,
|
|
pageDown: boolean,
|
|
},
|
|
toolbar: {
|
|
left: string[][],
|
|
right: string[][],
|
|
timeslider: string[][],
|
|
},
|
|
requireSession: boolean,
|
|
editOnly: boolean,
|
|
maxAge: number,
|
|
minify: boolean,
|
|
soffice: string | null,
|
|
docxExport: boolean,
|
|
allowUnknownFileEnds: boolean,
|
|
loglevel: string,
|
|
logLayoutType: string,
|
|
disableIPlogging: boolean, // deprecated — see ipLogging
|
|
ipLogging: 'full' | 'truncated' | 'anonymous',
|
|
automaticReconnectionTimeout: number,
|
|
loadTest: boolean,
|
|
dumpOnUncleanExit: boolean,
|
|
indentationOnNewLine: boolean,
|
|
logconfig: any | null,
|
|
sessionKey: string | null,
|
|
trustProxy: boolean,
|
|
cookie: {
|
|
keyRotationInterval: number,
|
|
prefix: string,
|
|
sameSite: boolean | "lax" | "strict" | "none" | undefined,
|
|
sessionLifetime: number,
|
|
sessionCleanup: boolean,
|
|
sessionRefreshInterval: number,
|
|
},
|
|
requireAuthentication: boolean,
|
|
requireAuthorization: boolean,
|
|
users: Record<string, any>,
|
|
sso: {
|
|
issuer: string,
|
|
clients?: {client_id: string}[]
|
|
},
|
|
showSettingsInAdminPage: boolean,
|
|
cleanup: {
|
|
enabled: boolean,
|
|
keepRevisions: number,
|
|
},
|
|
scrollWhenFocusLineIsOutOfViewport: {
|
|
percentage: {
|
|
editionAboveViewport: number,
|
|
editionBelowViewport: number,
|
|
},
|
|
duration: number,
|
|
percentageToScrollWhenUserPressesArrowUp: number,
|
|
scrollWhenCaretIsInTheLastLineOfViewport: boolean,
|
|
},
|
|
exposeVersion: boolean,
|
|
customLocaleStrings: Record<string, string>,
|
|
importExportRateLimiting: {
|
|
windowMs?: number,
|
|
max: number,
|
|
},
|
|
commitRateLimiting: {
|
|
duration: number,
|
|
points: number,
|
|
},
|
|
importMaxFileSize: number,
|
|
enableAdminUITests: boolean,
|
|
lowerCasePadIds: boolean,
|
|
randomVersionString: string,
|
|
gitVersion: string
|
|
updates: {
|
|
tier: 'off' | 'notify' | 'manual' | 'auto' | 'autonomous',
|
|
source: 'github',
|
|
channel: 'stable',
|
|
installMethod: 'auto' | 'git' | 'docker' | 'npm' | 'managed',
|
|
checkIntervalHours: number,
|
|
githubRepo: string,
|
|
requireAdminForStatus: boolean,
|
|
},
|
|
adminEmail: string | null,
|
|
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings">,
|
|
}
|
|
|
|
const settings: SettingsType = {
|
|
/* Root path of the installation */
|
|
root: absolutePaths.findEtherpadRoot(),
|
|
settingsFilename: absolutePaths.makeAbsolute(argv.settings || 'settings.json'),
|
|
credentialsFilename: absolutePaths.makeAbsolute(argv.credentials || 'credentials.json'),
|
|
/**
|
|
* The app title, visible e.g. in the browser window
|
|
*/
|
|
title: 'Etherpad',
|
|
|
|
/**
|
|
* Whether to show recent pads on the homepage
|
|
*/
|
|
showRecentPads: true,
|
|
|
|
/**
|
|
* Pathname of the favicon you want to use. If null, the skin's favicon is
|
|
* used if one is provided by the skin, otherwise the default Etherpad favicon
|
|
* is used. If this is a relative path it is interpreted as relative to the
|
|
* Etherpad root directory.
|
|
*/
|
|
favicon: null,
|
|
|
|
/**
|
|
* Canonical public origin of this Etherpad instance, e.g. "https://pad.example.com".
|
|
* When set, it is used to build absolute URLs in server-rendered output (currently
|
|
* the Open Graph / Twitter Card meta tags). When null, those URLs fall back to the
|
|
* incoming request's protocol+host, which is safe when Host/X-Forwarded-Host
|
|
* headers are trusted but should be configured explicitly in production to avoid
|
|
* client-controlled origin values appearing in og:url / og:image.
|
|
*
|
|
* No trailing slash. Must include scheme.
|
|
*/
|
|
publicURL: null,
|
|
ttl: {
|
|
AccessToken: 1 * 60 * 60, // 1 hour in seconds
|
|
AuthorizationCode: 10 * 60, // 10 minutes in seconds
|
|
ClientCredentials: 1 * 60 * 60, // 1 hour in seconds
|
|
IdToken: 1 * 60 * 60, // 1 hour in seconds
|
|
RefreshToken: 1 * 24 * 60 * 60, // 1 day in seconds
|
|
},
|
|
updateServer: "https://static.etherpad.org",
|
|
enableDarkMode: true,
|
|
enablePadWideSettings: false,
|
|
allowPadDeletionByAllUsers: false,
|
|
/*
|
|
* Skin name.
|
|
*
|
|
* Initialized to null, so we can spot an old configuration file and invite the
|
|
* user to update it before falling back to the default.
|
|
*/
|
|
skinName: null,
|
|
skinVariants: 'super-light-toolbar super-light-editor light-background',
|
|
/**
|
|
* The IP ep-lite should listen to
|
|
*/
|
|
ip: '0.0.0.0',
|
|
/**
|
|
* The Port ep-lite should listen to
|
|
*/
|
|
port: process.env.PORT || 9001,
|
|
/**
|
|
* Should we suppress Error messages from being in Pad Contents
|
|
*/
|
|
suppressErrorsInPadText: false,
|
|
/**
|
|
* The SSL signed server key and the Certificate Authority's own certificate
|
|
* default case: ep-lite does *not* use SSL. A signed server key is not required in this case.
|
|
*/
|
|
ssl: false,
|
|
/**
|
|
* socket.io transport methods
|
|
**/
|
|
socketTransportProtocols: ['websocket', 'polling'],
|
|
socketIo: {
|
|
/**
|
|
* Maximum permitted client message size (in bytes).
|
|
*
|
|
* All messages from clients that are larger than this will be rejected. Large values make it
|
|
* possible to paste large amounts of text, and plugins may require a larger value to work
|
|
* properly, but increasing the value increases susceptibility to denial of service attacks
|
|
* (malicious clients can exhaust memory).
|
|
*/
|
|
maxHttpBufferSize: 1000000,
|
|
},
|
|
/*
|
|
The authentication method used by the server.
|
|
The default value is sso
|
|
If you want to use the old authentication system, change this to apikey
|
|
*/
|
|
authenticationMethod: 'sso',
|
|
/*
|
|
* The Type of the database
|
|
*/
|
|
dbType: 'rustydb',
|
|
/**
|
|
* This setting is passed with dbType to ueberDB to set up the database
|
|
*/
|
|
dbSettings: null,
|
|
/**
|
|
* The default Text of a new pad
|
|
*/
|
|
defaultPadText: [
|
|
'Welcome to Etherpad!',
|
|
'',
|
|
'This pad text is synchronized as you type, so that everyone viewing this page sees the same ' +
|
|
'text. This allows you to collaborate seamlessly on documents!',
|
|
'',
|
|
'Etherpad on Github: https://github.com/ether/etherpad',
|
|
].join('\n'),
|
|
/**
|
|
* The default Pad Settings for a user (Can be overridden by changing the setting
|
|
*/
|
|
padOptions: {
|
|
noColors: false,
|
|
showControls: true,
|
|
showChat: true,
|
|
showLineNumbers: true,
|
|
useMonospaceFont: false,
|
|
userName: null,
|
|
userColor: null,
|
|
rtl: false,
|
|
alwaysShowChat: false,
|
|
chatAndUsers: false,
|
|
lang: null,
|
|
fadeInactiveAuthorColors: true,
|
|
enforceReadableAuthorColors: true,
|
|
},
|
|
/**
|
|
* Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this.
|
|
*/
|
|
enableMetrics: true,
|
|
/**
|
|
* Self-update subsystem (PR 1: tier 1 only).
|
|
* Tier "off" disables the version check entirely. Default "notify" shows a banner when behind.
|
|
*/
|
|
updates: {
|
|
tier: 'notify',
|
|
source: 'github',
|
|
channel: 'stable',
|
|
installMethod: 'auto',
|
|
checkIntervalHours: 6,
|
|
githubRepo: 'ether/etherpad',
|
|
// The /admin/update/status endpoint returns full info including currentVersion.
|
|
// Default false matches existing behavior: the version is already exposed via /health.
|
|
// Set true to require an authenticated admin session for the endpoint without
|
|
// disabling the updater itself.
|
|
requireAdminForStatus: false,
|
|
},
|
|
/**
|
|
* Contact address for admin notifications (updates, future security advisories).
|
|
* Null disables outbound mail from the updater.
|
|
*/
|
|
adminEmail: null,
|
|
/**
|
|
* Whether certain shortcut keys are enabled for a user in the pad
|
|
*/
|
|
padShortcutEnabled: {
|
|
altF9: true,
|
|
altC: true,
|
|
delete: true,
|
|
cmdShift2: true,
|
|
return: true,
|
|
esc: true,
|
|
cmdS: true,
|
|
tab: true,
|
|
cmdZ: true,
|
|
cmdY: true,
|
|
cmdB: true,
|
|
cmdI: true,
|
|
cmdU: true,
|
|
cmd5: true,
|
|
cmdShiftL: true,
|
|
cmdShiftN: true,
|
|
cmdShift1: true,
|
|
cmdShiftC: true,
|
|
cmdShiftD: true, // duplicate current line(s) — issue #6433
|
|
cmdShiftK: true, // delete current line(s) — issue #6433
|
|
cmdH: true,
|
|
ctrlHome: true,
|
|
pageUp: true,
|
|
pageDown: true,
|
|
},
|
|
/**
|
|
* The toolbar buttons and order.
|
|
*/
|
|
toolbar: {
|
|
left: [
|
|
['bold', 'italic', 'underline', 'strikethrough'],
|
|
['orderedlist', 'unorderedlist', 'indent', 'outdent'],
|
|
['undo', 'redo'],
|
|
['clearauthorship'],
|
|
],
|
|
right: [
|
|
['importexport', 'timeslider', 'savedrevision'],
|
|
['settings', 'embed', 'home'],
|
|
['showusers'],
|
|
],
|
|
timeslider: [
|
|
['timeslider_export', 'timeslider_settings', 'timeslider_returnToPad'],
|
|
],
|
|
},
|
|
/**
|
|
* A flag that requires any user to have a valid session (via the api) before accessing a pad
|
|
*/
|
|
requireSession: false,
|
|
/**
|
|
* A flag that prevents users from creating new pads
|
|
*/
|
|
editOnly: false,
|
|
/**
|
|
* Max age that responses will have (affects caching layer).
|
|
*/
|
|
maxAge: 1000 * 60 * 60 * 6, // 6 hours
|
|
/**
|
|
* A flag that shows if minification is enabled or not
|
|
*/
|
|
minify: true,
|
|
/**
|
|
* The path of the libreoffice executable
|
|
*/
|
|
soffice: null,
|
|
/**
|
|
* When true, the "Microsoft Word" export button downloads a .docx file (requires soffice).
|
|
* Set to false to revert to legacy .doc output.
|
|
*/
|
|
docxExport: true,
|
|
/**
|
|
* Should we support none natively supported file types on import?
|
|
*/
|
|
allowUnknownFileEnds: true,
|
|
/**
|
|
* The log level of log4js
|
|
*/
|
|
loglevel: defaultLogLevel,
|
|
/**
|
|
* The log layout type of log4js
|
|
*/
|
|
logLayoutType: defaultLogLayoutType,
|
|
/**
|
|
* Disable IP logging
|
|
*/
|
|
disableIPlogging: false,
|
|
ipLogging: 'anonymous',
|
|
/**
|
|
* Number of seconds to automatically reconnect pad
|
|
*/
|
|
automaticReconnectionTimeout: 0,
|
|
/**
|
|
* Disable Load Testing
|
|
*/
|
|
loadTest: false,
|
|
/**
|
|
* Disable dump of objects preventing a clean exit
|
|
*/
|
|
dumpOnUncleanExit: false,
|
|
/**
|
|
* Enable indentation on new lines
|
|
*/
|
|
indentationOnNewLine: true,
|
|
/*
|
|
* log4js appender configuration
|
|
*/
|
|
logconfig: null,
|
|
/*
|
|
* Deprecated cookie signing key.
|
|
*/
|
|
sessionKey: null,
|
|
/*
|
|
* Trust Proxy, whether or not trust the x-forwarded-for header.
|
|
*/
|
|
trustProxy: false,
|
|
/*
|
|
* Settings controlling the session cookie issued by Etherpad.
|
|
*/
|
|
cookie: {
|
|
keyRotationInterval: 1 * 24 * 60 * 60 * 1000,
|
|
prefix: '',
|
|
sameSite: 'lax',
|
|
sessionLifetime: 10 * 24 * 60 * 60 * 1000,
|
|
sessionCleanup: true,
|
|
sessionRefreshInterval: 1 * 24 * 60 * 60 * 1000,
|
|
},
|
|
/*
|
|
* This setting is used if you need authentication and/or
|
|
* authorization. Note: /admin always requires authentication, and
|
|
* either authorization by a module, or a user with is_admin set
|
|
*/
|
|
requireAuthentication: false,
|
|
requireAuthorization: false,
|
|
users: {},
|
|
/*
|
|
* This setting is used for configuring sso
|
|
*/
|
|
sso: {
|
|
issuer: "http://localhost:9001"
|
|
},
|
|
/*
|
|
* Show settings in admin page, by default it is true
|
|
*/
|
|
showSettingsInAdminPage: true,
|
|
/*
|
|
* Settings for cleanup of pads
|
|
*/
|
|
cleanup: {
|
|
enabled: false,
|
|
keepRevisions: 100,
|
|
},
|
|
/*
|
|
* By default, when caret is moved out of viewport, it scrolls the minimum
|
|
* height needed to make this line visible.
|
|
*/
|
|
scrollWhenFocusLineIsOutOfViewport: {
|
|
/*
|
|
* Percentage of viewport height to be additionally scrolled.
|
|
*/
|
|
percentage: {
|
|
editionAboveViewport: 0,
|
|
editionBelowViewport: 0,
|
|
},
|
|
/*
|
|
* Time (in milliseconds) used to animate the scroll transition. Set to 0 to
|
|
* disable animation
|
|
*/
|
|
duration: 0,
|
|
/*
|
|
* Percentage of viewport height to be additionally scrolled when user presses arrow up
|
|
* in the line of the top of the viewport.
|
|
*/
|
|
percentageToScrollWhenUserPressesArrowUp: 0,
|
|
/*
|
|
* Flag to control if it should scroll when user places the caret in the last
|
|
* line of the viewport
|
|
*/
|
|
scrollWhenCaretIsInTheLastLineOfViewport: false,
|
|
},
|
|
/*
|
|
* Expose Etherpad version in the web interface and in the Server http header.
|
|
*
|
|
* Do not enable on production machines.
|
|
*/
|
|
exposeVersion: false,
|
|
/*
|
|
* Override any strings found in locale directories
|
|
*/
|
|
customLocaleStrings: {},
|
|
/*
|
|
* From Etherpad 1.8.3 onwards, import and export of pads is always rate
|
|
* limited.
|
|
*
|
|
* The default is to allow at most 10 requests per IP in a 90 seconds window.
|
|
* After that the import/export request is rejected.
|
|
*
|
|
* See https://github.com/nfriedly/express-rate-limit for more options
|
|
*/
|
|
importExportRateLimiting: {
|
|
// duration of the rate limit window (milliseconds)
|
|
windowMs: 90000,
|
|
// maximum number of requests per IP to allow during the rate limit window
|
|
max: 10,
|
|
},
|
|
/*
|
|
* From Etherpad 1.9.0 onwards, commits from individual users are rate limited
|
|
*
|
|
* The default is to allow at most 10 changes per IP in a 1 second window.
|
|
* After that the change is rejected.
|
|
*
|
|
* See https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#websocket-single-connection-prevent-flooding for more options
|
|
*/
|
|
commitRateLimiting: {
|
|
// duration of the rate limit window (seconds)
|
|
duration: 1,
|
|
// maximum number of changes per IP to allow during the rate limit window
|
|
points: 10,
|
|
},
|
|
/*
|
|
* From Etherpad 1.8.3 onwards, the maximum allowed size for a single imported
|
|
* file is always bounded.
|
|
*
|
|
* File size is specified in bytes. Default is 50 MB.
|
|
*/
|
|
importMaxFileSize: 50 * 1024 * 1024,
|
|
/*
|
|
* Disable Admin UI tests
|
|
*/
|
|
enableAdminUITests: false,
|
|
/*
|
|
* Enable auto conversion of pad Ids to lowercase.
|
|
* e.g. /p/EtHeRpAd to /p/etherpad
|
|
*/
|
|
lowerCasePadIds: false,
|
|
randomVersionString: '2123',
|
|
getPublicSettings: () => {
|
|
return {
|
|
gitVersion: settings.gitVersion,
|
|
toolbar: settings.toolbar,
|
|
exposeVersion: settings.exposeVersion,
|
|
randomVersionString: settings.randomVersionString,
|
|
title: settings.title,
|
|
skinName: settings.skinName,
|
|
skinVariants: settings.skinVariants,
|
|
enablePadWideSettings: settings.enablePadWideSettings,
|
|
}
|
|
},
|
|
gitVersion: getGitCommit(),
|
|
}
|
|
|
|
export default settings;
|
|
// CJS compatibility: plugins use require('ep_etherpad-lite/node/utils/Settings')
|
|
// and expect settings properties directly on the module object, not under .default
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
const currentExports = module.exports;
|
|
for (const key of Object.keys(settings)) {
|
|
if (!(key in currentExports)) {
|
|
Object.defineProperty(currentExports, key, {
|
|
get: () => (settings as any)[key],
|
|
set: (v: any) => { (settings as any)[key] = v; },
|
|
enumerable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This setting is passed with dbType to ueberDB to set up the database
|
|
*/
|
|
settings.dbSettings = {filename: path.join(settings.root, 'var/rusty.db')};
|
|
// END OF SETTINGS
|
|
|
|
export const sofficeAvailable = () => {
|
|
if (settings.soffice != null) {
|
|
return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes';
|
|
} else {
|
|
return 'no';
|
|
}
|
|
};
|
|
|
|
export const exportAvailable = () => sofficeAvailable();
|
|
|
|
|
|
// Return etherpad version from package.json
|
|
export const getEpVersion = () => require('../../package.json').version;
|
|
|
|
|
|
|
|
/**
|
|
* Receives a settingsObj and, if the property name is a valid configuration
|
|
* item, stores it in the module's exported properties via a side effect.
|
|
*
|
|
* This code refactors a previous version that copied & pasted the same code for
|
|
* both "settings.json" and "credentials.json".
|
|
*/
|
|
const storeSettings = (settingsObj: any) => {
|
|
for (const i of Object.keys(settingsObj || {})) {
|
|
if (nonSettings.includes(i)) {
|
|
logger.warn(`Ignoring setting: '${i}'`);
|
|
continue;
|
|
}
|
|
|
|
// test if the setting starts with a lowercase character
|
|
if (i.charAt(0).search('[a-z]') !== 0) {
|
|
logger.warn(`Settings should start with a lowercase character: '${i}'`);
|
|
}
|
|
|
|
// we know this setting, so we overwrite it
|
|
// or it's a settings hash, specific to a plugin
|
|
// @ts-ignore
|
|
if (settings[i] !== undefined || i.indexOf('ep_') === 0) {
|
|
if (_.isObject(settingsObj[i]) && !Array.isArray(settingsObj[i])) {
|
|
// @ts-ignore
|
|
settings[i] = _.defaults(settingsObj[i], settings[i]);
|
|
} else {
|
|
// @ts-ignore
|
|
settings[i] = settingsObj[i];
|
|
}
|
|
} else {
|
|
// this setting is unknown, output a warning and throw it away
|
|
logger.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`);
|
|
}
|
|
}
|
|
};
|
|
|
|
/*
|
|
* If stringValue is a numeric string, or its value is "true" or "false", coerce
|
|
* them to appropriate JS types. Otherwise return stringValue as-is.
|
|
*
|
|
* Please note that this function is used for converting types for default
|
|
* values in the settings file (for example: "${PORT:9001}"), and that there is
|
|
* no coercition for "null" values.
|
|
*
|
|
* If the user wants a variable to be null by default, he'll have to use the
|
|
* short syntax "${SOFFICE}", and not "${SOFFICE:null}": the latter would result
|
|
* in the literal string "null", instead.
|
|
*/
|
|
const coerceValue = (stringValue: string) => {
|
|
// cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number
|
|
// @ts-ignore
|
|
const isNumeric = !isNaN(stringValue) && !isNaN(parseFloat(stringValue) && isFinite(stringValue));
|
|
|
|
if (isNumeric) {
|
|
// detected numeric string. Coerce to a number
|
|
|
|
return +stringValue;
|
|
}
|
|
|
|
switch (stringValue) {
|
|
case 'true':
|
|
return true;
|
|
case 'false':
|
|
return false;
|
|
case 'undefined':
|
|
return undefined;
|
|
case 'null':
|
|
return null;
|
|
default:
|
|
return stringValue;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Takes a javascript object containing Etherpad's configuration, and returns
|
|
* another object, in which all the string properties whose value is of the form
|
|
* "${ENV_VAR}" or "${ENV_VAR:default_value}" got their value replaced with the
|
|
* contents of the given environment variable, or with a default value.
|
|
*
|
|
* By definition, an environment variable's value is always a string. However,
|
|
* the code base makes use of the various json types. To maintain compatiblity,
|
|
* some heuristics is applied:
|
|
*
|
|
* - if ENV_VAR does not exist in the environment, null is returned;
|
|
* - if ENV_VAR's value is "true" or "false", it is converted to the js boolean
|
|
* values true or false;
|
|
* - if ENV_VAR's value looks like a number, it is converted to a js number
|
|
* (details in the code).
|
|
*
|
|
* The following is a scheme of the behaviour of this function:
|
|
*
|
|
* +---------------------------+---------------+------------------+
|
|
* | Configuration string in | Value of | Resulting confi- |
|
|
* | settings.json | ENV_VAR | guration value |
|
|
* |---------------------------|---------------|------------------|
|
|
* | "${ENV_VAR}" | "some_string" | "some_string" |
|
|
* | "${ENV_VAR}" | "9001" | 9001 |
|
|
* | "${ENV_VAR}" | undefined | null |
|
|
* | "${ENV_VAR:some_default}" | "some_string" | "some_string" |
|
|
* | "${ENV_VAR:some_default}" | undefined | "some_default" |
|
|
* +---------------------------+---------------+------------------+
|
|
*
|
|
* IMPLEMENTATION NOTE: variable substitution is performed doing a round trip
|
|
* conversion to/from json, using a custom replacer parameter in
|
|
* JSON.stringify(), and parsing the JSON back again. This ensures that
|
|
* environment variable replacement is performed even on nested objects.
|
|
*
|
|
* see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter
|
|
*/
|
|
const lookupEnvironmentVariables = (obj: MapArrayType<any>) => {
|
|
const replaceEnvs = (obj: MapArrayType<any>) => {
|
|
for (let [key, value] of Object.entries(obj)) {
|
|
/*
|
|
* the first invocation of replacer() is with an empty key. Just go on, or
|
|
* we would zap the entire object.
|
|
*/
|
|
if (key === '') {
|
|
obj[key] = value;
|
|
continue
|
|
}
|
|
|
|
/*
|
|
* If we received from the configuration file a number, a boolean or
|
|
* something that is not a string, we can be sure that it was a literal
|
|
* value. No need to perform any variable substitution.
|
|
*
|
|
* The environment variable expansion syntax "${ENV_VAR}" is just a string
|
|
* of specific form, after all.
|
|
*/
|
|
|
|
if(key === 'undefined' || value === undefined) {
|
|
delete obj[key]
|
|
continue
|
|
}
|
|
|
|
if ((typeof value !== 'string' && typeof value !== 'object') || value === null) {
|
|
obj[key] = value;
|
|
continue
|
|
}
|
|
|
|
if (typeof obj[key] === "object") {
|
|
replaceEnvs(obj[key]);
|
|
continue
|
|
}
|
|
|
|
|
|
/*
|
|
* Let's check if the string value looks like a variable expansion (e.g.:
|
|
* "${ENV_VAR}" or "${ENV_VAR:default_value}")
|
|
*/
|
|
// MUXATOR 2019-03-21: we could use named capture groups here once we migrate to nodejs v10
|
|
const match = value.match(/^\$\{([^:]*)(:((.|\n)*))?\}$/);
|
|
|
|
if (match == null) {
|
|
// no match: use the value literally, without any substitution
|
|
obj[key] = value;
|
|
continue
|
|
}
|
|
|
|
/*
|
|
* We found the name of an environment variable. Let's read its actual value
|
|
* and its default value, if given
|
|
*/
|
|
const envVarName = match[1];
|
|
const envVarValue = process.env[envVarName];
|
|
const defaultValue = match[3];
|
|
|
|
if ((envVarValue === undefined) && (defaultValue === undefined)) {
|
|
logger.warn(`Environment variable "${envVarName}" does not contain any value for ` +
|
|
`configuration key "${key}", and no default was given. Using null. ` +
|
|
'THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF ETHERPAD; you should ' +
|
|
'explicitly use "null" as the default if you want to continue to use null.');
|
|
|
|
/*
|
|
* We have to return null, because if we just returned undefined, the
|
|
* configuration item "key" would be stripped from the returned object.
|
|
*/
|
|
obj[key] = null;
|
|
continue
|
|
}
|
|
|
|
if ((envVarValue === undefined) && (defaultValue !== undefined)) {
|
|
logger.debug(`Environment variable "${envVarName}" not found for ` +
|
|
`configuration key "${key}". Falling back to default value.`);
|
|
|
|
obj[key] = coerceValue(defaultValue);
|
|
continue
|
|
}
|
|
|
|
// envVarName contained some value.
|
|
|
|
/*
|
|
* For numeric and boolean strings let's convert it to proper types before
|
|
* returning it, in order to maintain backward compatibility.
|
|
*/
|
|
logger.debug(
|
|
`Configuration key "${key}" will be read from environment variable "${envVarName}"`);
|
|
|
|
obj[key] = coerceValue(envVarValue!);
|
|
}
|
|
return obj
|
|
}
|
|
|
|
replaceEnvs(obj);
|
|
|
|
// Add plugin ENV variables
|
|
|
|
/**
|
|
* If the key contains a double underscore, it's a plugin variable
|
|
* E.g.
|
|
*/
|
|
let treeEntries = new Map<string, string | undefined>
|
|
const root = new SettingsNode("EP")
|
|
|
|
for (let [env, envVal] of Object.entries(process.env)) {
|
|
if (!env.startsWith("EP")) continue
|
|
treeEntries.set(env, envVal)
|
|
}
|
|
treeEntries.forEach((value, key) => {
|
|
let pathToKey = key.split("__")
|
|
let currentNode = root
|
|
let depth = 0
|
|
depth++
|
|
currentNode.addChild(pathToKey, value!)
|
|
})
|
|
|
|
//console.log(root.collectFromLeafsUpwards())
|
|
const rooting = root.collectFromLeafsUpwards()
|
|
obj = Object.assign(obj, rooting)
|
|
return obj;
|
|
};
|
|
|
|
|
|
|
|
export const reloadSettings = () => {
|
|
const settingsParsed = parseSettings(settings?.settingsFilename, true);
|
|
const credentials = parseSettings(settings.credentialsFilename, false);
|
|
storeSettings(settingsParsed);
|
|
storeSettings(credentials);
|
|
|
|
// Emit a clear migration warning when the deprecated abiword setting is detected.
|
|
if (settingsParsed && (settingsParsed as any).abiword != null) {
|
|
logger.warn(
|
|
'The "abiword" setting is no longer supported and has been ignored. ' +
|
|
'Abiword import/export support has been removed. ' +
|
|
'Please install LibreOffice and set "soffice" to its executable path instead.'
|
|
);
|
|
}
|
|
|
|
// Deprecation shim: if the operator set the legacy boolean `disableIPlogging`
|
|
// without also setting the new tri-state `ipLogging`, map the boolean over
|
|
// once and emit a WARN. An explicitly-set `ipLogging` always wins.
|
|
if (settingsParsed != null && 'disableIPlogging' in (settingsParsed as any) &&
|
|
!('ipLogging' in (settingsParsed as any))) {
|
|
logger.warn(
|
|
'`disableIPlogging` is deprecated; use `ipLogging: "anonymous"` ' +
|
|
'(or "truncated" / "full") instead.');
|
|
settings.ipLogging = (settingsParsed as any).disableIPlogging ? 'anonymous' : 'full';
|
|
}
|
|
|
|
// Validate `ipLogging`. anonymizeIp() would otherwise silently treat an
|
|
// unknown value as "truncated" and ship partially-redacted IPs.
|
|
const validIpLogging = ['full', 'truncated', 'anonymous'];
|
|
if (!validIpLogging.includes(settings.ipLogging as any)) {
|
|
logger.warn(
|
|
`ipLogging="${settings.ipLogging}" is not one of ` +
|
|
`${validIpLogging.join(', ')}; falling back to "anonymous".`);
|
|
settings.ipLogging = 'anonymous';
|
|
}
|
|
|
|
// Init logging config
|
|
settings.logconfig = defaultLogConfig(
|
|
settings.loglevel ? settings.loglevel : defaultLogLevel,
|
|
settings.logLayoutType ? settings.logLayoutType : defaultLogLayoutType
|
|
);
|
|
logger.warn("loglevel: " + settings.loglevel);
|
|
logger.warn("logLayoutType: " + settings.logLayoutType);
|
|
initLogging(settings.logconfig);
|
|
|
|
if (!settings.skinName) {
|
|
logger.warn('No "skinName" parameter found. Please check out settings.json.template and ' +
|
|
'update your settings.json. Falling back to the default "colibris".');
|
|
settings.skinName = 'colibris';
|
|
}
|
|
|
|
if (!settings.socketTransportProtocols.includes("websocket") || !settings.socketTransportProtocols.includes("polling")) {
|
|
logger.warn("Invalid socketTransportProtocols setting. Please check out settings.json.template and update your settings.json. Falling back to the default ['websocket', 'polling'].");
|
|
settings.socketTransportProtocols = ['websocket', 'polling'];
|
|
}
|
|
|
|
// checks if skinName has an acceptable value, otherwise falls back to "colibris"
|
|
if (settings.skinName) {
|
|
const skinBasePath = path.join(settings.root, 'src', 'static', 'skins');
|
|
const countPieces = settings.skinName.split(path.sep).length;
|
|
|
|
if (countPieces !== 1) {
|
|
logger.error(`skinName must be the name of a directory under "${skinBasePath}". This is ` +
|
|
`not valid: "${settings.skinName}". Falling back to the default "colibris".`);
|
|
|
|
settings.skinName = 'colibris';
|
|
}
|
|
|
|
// informative variable, just for the log messages
|
|
let skinPath = path.join(skinBasePath, settings.skinName);
|
|
|
|
// what if someone sets skinName == ".." or "."? We catch him!
|
|
if (!absolutePaths.isSubdir(skinBasePath, skinPath)) {
|
|
logger.error(`Skin path ${skinPath} must be a subdirectory of ${skinBasePath}. ` +
|
|
'Falling back to the default "colibris".');
|
|
|
|
settings.skinName = 'colibris';
|
|
skinPath = path.join(skinBasePath, settings.skinName);
|
|
}
|
|
|
|
if (!fs.existsSync(skinPath)) {
|
|
logger.error(`Skin path ${skinPath} does not exist. Falling back to the default "colibris".`);
|
|
settings.skinName = 'colibris';
|
|
skinPath = path.join(skinBasePath, settings.skinName);
|
|
}
|
|
|
|
logger.info(`Using skin "${settings.skinName}" in dir: ${skinPath}`);
|
|
}
|
|
|
|
if (settings.soffice) {
|
|
fs.exists(settings.soffice, (exists: boolean) => {
|
|
if (!exists) {
|
|
const sofficeError =
|
|
'soffice (libreoffice) does not exist at this path, check your settings file.';
|
|
|
|
if (!settings.suppressErrorsInPadText) {
|
|
settings.defaultPadText += `\nError: ${sofficeError}${suppressDisableMsg}`;
|
|
}
|
|
logger.error(`${sofficeError} File location: ${settings.soffice}`);
|
|
settings.soffice = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
const sessionkeyFilename = absolutePaths.makeAbsolute(argv.sessionkey || './SESSIONKEY.txt');
|
|
if (!settings.sessionKey) {
|
|
try {
|
|
settings.sessionKey = fs.readFileSync(sessionkeyFilename, 'utf8');
|
|
logger.info(`Session key loaded from: ${sessionkeyFilename}`);
|
|
} catch (err) { /* ignored */
|
|
}
|
|
const keyRotationEnabled = settings.cookie.keyRotationInterval && settings.cookie.sessionLifetime;
|
|
if (!settings.sessionKey && !keyRotationEnabled) {
|
|
logger.info(
|
|
`Session key file "${sessionkeyFilename}" not found. Creating with random contents.`);
|
|
settings.sessionKey = randomString(32);
|
|
fs.writeFileSync(sessionkeyFilename, settings.sessionKey, 'utf8');
|
|
}
|
|
} else {
|
|
logger.warn('Declaring the sessionKey in the settings.json is deprecated. ' +
|
|
'This value is auto-generated now. Please remove the setting from the file. -- ' +
|
|
'If you are seeing this error after restarting using the Admin User ' +
|
|
'Interface then you can ignore this message.');
|
|
}
|
|
if (settings.sessionKey) {
|
|
logger.warn(`The sessionKey setting and ${sessionkeyFilename} file are deprecated; ` +
|
|
'use automatic key rotation instead (see the cookie.keyRotationInterval setting).');
|
|
}
|
|
|
|
// Validate cookie prefix to prevent header injection via cookie names
|
|
if (settings.cookie.prefix && !/^[a-zA-Z0-9_-]*$/.test(settings.cookie.prefix)) {
|
|
logger.error(`cookie.prefix "${settings.cookie.prefix}" contains invalid characters. ` +
|
|
'Only alphanumeric characters, hyphens, and underscores are allowed. Using empty prefix.');
|
|
settings.cookie.prefix = '';
|
|
}
|
|
|
|
if (settings.dbType === 'dirty') {
|
|
const dirtyWarning = 'DirtyDB is used. This is not recommended for production.';
|
|
if (!settings.suppressErrorsInPadText) {
|
|
settings.defaultPadText += `\nWarning: ${dirtyWarning}${suppressDisableMsg}`;
|
|
}
|
|
|
|
settings.dbSettings.filename = absolutePaths.makeAbsolute(settings.dbSettings.filename);
|
|
logger.warn(`${dirtyWarning} File location: ${settings.dbSettings.filename}`);
|
|
}
|
|
|
|
if (settings.dbType === 'rustydb' || settings.dbType === "sqlite") {
|
|
settings.dbSettings.filename = absolutePaths.makeAbsolute(settings.dbSettings.filename);
|
|
logger.warn(`File location: ${settings.dbSettings.filename}`);
|
|
}
|
|
|
|
|
|
if (settings.ip === '') {
|
|
// using Unix socket for connectivity
|
|
logger.warn('The settings file contains an empty string ("") for the "ip" parameter. The ' +
|
|
'"port" parameter will be interpreted as the path to a Unix socket to bind at.');
|
|
}
|
|
|
|
/*
|
|
* Etherpad appends this token as a ?v= query parameter on static assets
|
|
* and as the content seed for the padbootstrap-<hash>.min.js bundles, so
|
|
* clients invalidate their cache when a release goes out.
|
|
*
|
|
* Historically this was `randomString(4)`, regenerated on every boot. That
|
|
* broke horizontally-scaled deployments (multi-pod behind an ingress):
|
|
* every pod hashed the bootstrap bundle with its own seed, so an HTML
|
|
* response from pod A referenced `padbootstrap-ABCD.min.js` while pod B
|
|
* only served `padbootstrap-WXYZ.min.js`, producing 404s on any cross-pod
|
|
* request (issue #7213).
|
|
*
|
|
* Derive the token deterministically from the Etherpad version and
|
|
* whatever git SHA is available. Pods that ship the same artifact now
|
|
* produce the same hash, and the token still rotates per release so
|
|
* caches invalidate correctly.
|
|
*
|
|
* Precedence: ETHERPAD_VERSION_STRING env var (explicit integrator
|
|
* override) > sha256(version + "|" + gitVersion) > package.json version.
|
|
*
|
|
* For the original cache-busting rationale, see PR #3958.
|
|
*/
|
|
const explicit = process.env.ETHERPAD_VERSION_STRING;
|
|
if (explicit) {
|
|
settings.randomVersionString = explicit;
|
|
} else {
|
|
const pkgVersion = require('../../package.json').version as string;
|
|
settings.randomVersionString = createHash('sha256')
|
|
.update(`${pkgVersion}|${settings.gitVersion || ''}`)
|
|
.digest('hex')
|
|
.slice(0, 8);
|
|
}
|
|
logger.info(`String used for versioning assets: ${settings.randomVersionString}`);
|
|
};
|
|
|
|
export const exportedForTestingOnly = {
|
|
parseSettings,
|
|
};
|
|
|
|
// initially load settings
|
|
reloadSettings();
|