Fixed formatting.

This commit is contained in:
SamTV12345 2024-04-17 22:01:04 +02:00
parent 9f5ff6171a
commit f115ea9241
340 changed files with 77690 additions and 66928 deletions

View file

@ -278,6 +278,9 @@ importers:
specifier: ^0.9.2
version: 0.9.2
devDependencies:
'@biomejs/biome':
specifier: 1.7.0
version: 1.7.0
'@playwright/test':
specifier: ^1.43.1
version: 1.43.1
@ -721,6 +724,94 @@ packages:
to-fast-properties: 2.0.0
dev: true
/@biomejs/biome@1.7.0:
resolution: {integrity: sha512-mejiRhnAq6UrXtYvjWJUKdstcT58n0/FfKemFf3d2Ou0HxOdS88HQmWtQ/UgyZvOEPD572YbFTb6IheyROpqkw==}
engines: {node: '>=14.21.3'}
hasBin: true
requiresBuild: true
optionalDependencies:
'@biomejs/cli-darwin-arm64': 1.7.0
'@biomejs/cli-darwin-x64': 1.7.0
'@biomejs/cli-linux-arm64': 1.7.0
'@biomejs/cli-linux-arm64-musl': 1.7.0
'@biomejs/cli-linux-x64': 1.7.0
'@biomejs/cli-linux-x64-musl': 1.7.0
'@biomejs/cli-win32-arm64': 1.7.0
'@biomejs/cli-win32-x64': 1.7.0
dev: true
/@biomejs/cli-darwin-arm64@1.7.0:
resolution: {integrity: sha512-12TaeaKHU4SAZt0fQJ2bYk1jUb4foope7LmgDE5p3c0uMxd3mFkg1k7G721T+K6UHYULcSOQDsNNM8DhYi8Irg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@biomejs/cli-darwin-x64@1.7.0:
resolution: {integrity: sha512-6Qq1BSIB0cpp0cQNqO/+EiUV7FE3jMpF6w7+AgIBXp0oJxUWb2Ff0RDZdO9bfzkimXD58j0vGpNHMGnCcjDV2Q==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@biomejs/cli-linux-arm64-musl@1.7.0:
resolution: {integrity: sha512-pwIY80nU7SAxrVVZ6HD9ah1pruwh9ZqlSR0Nvbg4ZJqQa0POhiB+RJx7+/1Ml2mTZdrl8kb/YiwQpD16uwb5wg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@biomejs/cli-linux-arm64@1.7.0:
resolution: {integrity: sha512-GwSci7xBJ2j1CrdDXDUVXnUtrvypEz/xmiYPpFeVdlX5p95eXx+7FekPPbJfhGGw5WKSsKZ+V8AAlbN+kUwJWw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@biomejs/cli-linux-x64-musl@1.7.0:
resolution: {integrity: sha512-KzCA0mW4LSbCd7XZWaEJvTOTTBjfJoVEXkfq1fsXxww1HB+ww5PGMbhbIcbYCsj2CTJUifeD5hOkyuBVppU1xQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@biomejs/cli-linux-x64@1.7.0:
resolution: {integrity: sha512-1y+odKQsyHcw0JCGRuqhbx7Y6jxOVSh4lGIVDdJxW1b55yD22DY1kcMEfhUte6f95OIc2uqfkwtiI6xQAiZJdw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@biomejs/cli-win32-arm64@1.7.0:
resolution: {integrity: sha512-AvLDUYZBpOUFgS/mni4VruIoVV3uSGbKSkZQBPXsHgL0w4KttLll3NBrVanmWxOHsom6C6ocHLyfAY8HUc8TXg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@biomejs/cli-win32-x64@1.7.0:
resolution: {integrity: sha512-Pylm00BAAuLVb40IH9PC17432BTsY8K4pSUvhvgR1eaalnMaD6ug9SYJTTzKDbT6r24MPAGCTiSZERyhGkGzFQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@docsearch/css@3.6.0:
resolution: {integrity: sha512-+sbxb71sWre+PwDK7X2T8+bhS6clcVMLwBPznX45Qu6opJcgRjAp7gYSDzVFp187J+feSj5dNBN1mJoi6ckkUQ==}
dev: true

View file

@ -1,139 +1,108 @@
'use strict';
"use strict";
// This is a workaround for https://github.com/eslint/eslint/issues/3458
require('eslint-config-etherpad/patch/modern-module-resolution');
require("eslint-config-etherpad/patch/modern-module-resolution");
module.exports = {
ignorePatterns: [
'/static/js/vendors/browser.js',
'/static/js/vendors/farbtastic.js',
'/static/js/vendors/gritter.js',
'/static/js/vendors/html10n.js',
'/static/js/vendors/jquery.js',
'/static/js/vendors/nice-select.js',
'/tests/frontend/lib/',
],
overrides: [
{
files: [
'**/.eslintrc.*',
],
extends: 'etherpad/node',
},
{
files: [
'**/*',
],
excludedFiles: [
'**/.eslintrc.*',
'tests/frontend/**/*',
],
extends: 'etherpad/node',
},
{
files: [
'static/**/*',
'tests/frontend/helper.js',
'tests/frontend/helper/**/*',
],
excludedFiles: [
'**/.eslintrc.*',
],
extends: 'etherpad/browser',
env: {
'shared-node-browser': true,
},
overrides: [
{
files: [
'tests/frontend/helper/**/*',
],
globals: {
helper: 'readonly',
},
},
],
},
{
files: [
'tests/**/*',
],
excludedFiles: [
'**/.eslintrc.*',
'tests/frontend/cypress/**/*',
'tests/frontend/helper.js',
'tests/frontend/helper/**/*',
'tests/frontend/travis/**/*',
'tests/ratelimit/**/*',
],
extends: 'etherpad/tests',
rules: {
'mocha/no-exports': 'off',
'mocha/no-top-level-hooks': 'off',
},
},
{
files: [
'tests/backend/**/*',
],
excludedFiles: [
'**/.eslintrc.*',
],
extends: 'etherpad/tests/backend',
overrides: [
{
files: [
'tests/backend/**/*',
],
excludedFiles: [
'tests/backend/specs/**/*',
],
rules: {
'mocha/no-exports': 'off',
'mocha/no-top-level-hooks': 'off',
},
},
],
},
{
files: [
'tests/frontend/**/*',
],
excludedFiles: [
'**/.eslintrc.*',
'tests/frontend/cypress/**/*',
'tests/frontend/helper.js',
'tests/frontend/helper/**/*',
'tests/frontend/travis/**/*',
],
extends: 'etherpad/tests/frontend',
overrides: [
{
files: [
'tests/frontend/**/*',
],
excludedFiles: [
'tests/frontend/specs/**/*',
],
rules: {
'mocha/no-exports': 'off',
'mocha/no-top-level-hooks': 'off',
},
},
],
},
{
files: [
'tests/frontend/cypress/**/*',
],
extends: 'etherpad/tests/cypress',
},
{
files: [
'tests/frontend/travis/**/*',
],
extends: 'etherpad/node',
},
],
root: true,
ignorePatterns: [
"/static/js/vendors/browser.js",
"/static/js/vendors/farbtastic.js",
"/static/js/vendors/gritter.js",
"/static/js/vendors/html10n.js",
"/static/js/vendors/jquery.js",
"/static/js/vendors/nice-select.js",
"/tests/frontend/lib/",
],
overrides: [
{
files: ["**/.eslintrc.*"],
extends: "etherpad/node",
},
{
files: ["**/*"],
excludedFiles: ["**/.eslintrc.*", "tests/frontend/**/*"],
extends: "etherpad/node",
},
{
files: [
"static/**/*",
"tests/frontend/helper.js",
"tests/frontend/helper/**/*",
],
excludedFiles: ["**/.eslintrc.*"],
extends: "etherpad/browser",
env: {
"shared-node-browser": true,
},
overrides: [
{
files: ["tests/frontend/helper/**/*"],
globals: {
helper: "readonly",
},
},
],
},
{
files: ["tests/**/*"],
excludedFiles: [
"**/.eslintrc.*",
"tests/frontend/cypress/**/*",
"tests/frontend/helper.js",
"tests/frontend/helper/**/*",
"tests/frontend/travis/**/*",
"tests/ratelimit/**/*",
],
extends: "etherpad/tests",
rules: {
"mocha/no-exports": "off",
"mocha/no-top-level-hooks": "off",
},
},
{
files: ["tests/backend/**/*"],
excludedFiles: ["**/.eslintrc.*"],
extends: "etherpad/tests/backend",
overrides: [
{
files: ["tests/backend/**/*"],
excludedFiles: ["tests/backend/specs/**/*"],
rules: {
"mocha/no-exports": "off",
"mocha/no-top-level-hooks": "off",
},
},
],
},
{
files: ["tests/frontend/**/*"],
excludedFiles: [
"**/.eslintrc.*",
"tests/frontend/cypress/**/*",
"tests/frontend/helper.js",
"tests/frontend/helper/**/*",
"tests/frontend/travis/**/*",
],
extends: "etherpad/tests/frontend",
overrides: [
{
files: ["tests/frontend/**/*"],
excludedFiles: ["tests/frontend/specs/**/*"],
rules: {
"mocha/no-exports": "off",
"mocha/no-top-level-hooks": "off",
},
},
],
},
{
files: ["tests/frontend/cypress/**/*"],
extends: "etherpad/tests/cypress",
},
{
files: ["tests/frontend/travis/**/*"],
extends: "etherpad/node",
},
],
root: true,
};

12
src/biome.json Normal file
View file

@ -0,0 +1,12 @@
{
"$schema": "https://biomejs.dev/schemas/1.7.0/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}

View file

@ -1,117 +1,117 @@
{
"parts": [
{
"name": "DB",
"hooks": {
"shutdown": "ep_etherpad-lite/node/db/DB"
}
},
{
"name": "Minify",
"hooks": {
"shutdown": "ep_etherpad-lite/node/utils/Minify"
}
},
{
"name": "express",
"hooks": {
"createServer": "ep_etherpad-lite/node/hooks/express",
"restartServer": "ep_etherpad-lite/node/hooks/express",
"shutdown": "ep_etherpad-lite/node/hooks/express"
}
},
{
"name": "static",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/express/static"
}
},
{
"name": "stats",
"hooks": {
"shutdown": "ep_etherpad-lite/node/stats"
}
},
{
"name": "i18n",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/i18n"
}
},
{
"name": "specialpages",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/specialpages",
"expressPreSession": "ep_etherpad-lite/node/hooks/express/specialpages"
}
},
{
"name": "oauth2",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/security/OAuth2Provider"
}
},
{
"name": "padurlsanitize",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/padurlsanitize"
}
},
{
"name": "apicalls",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/express/apicalls"
}
},
{
"name": "importexport",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/importexport"
}
},
{
"name": "errorhandling",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/errorhandling"
}
},
{
"name": "socketio",
"hooks": {
"expressCloseServer": "ep_etherpad-lite/node/hooks/express/socketio",
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/socketio",
"socketio": "ep_etherpad-lite/node/handler/PadMessageHandler"
}
},
{
"name": "tests",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/express/tests"
}
},
{
"name": "admin",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/admin"
}
},
{
"name": "adminplugins",
"hooks": {
"socketio": "ep_etherpad-lite/node/hooks/express/adminplugins"
}
},
{
"name": "adminsettings",
"hooks": {
"socketio": "ep_etherpad-lite/node/hooks/express/adminsettings"
}
},
{
"name": "openapi",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/express/openapi"
}
}
]
"parts": [
{
"name": "DB",
"hooks": {
"shutdown": "ep_etherpad-lite/node/db/DB"
}
},
{
"name": "Minify",
"hooks": {
"shutdown": "ep_etherpad-lite/node/utils/Minify"
}
},
{
"name": "express",
"hooks": {
"createServer": "ep_etherpad-lite/node/hooks/express",
"restartServer": "ep_etherpad-lite/node/hooks/express",
"shutdown": "ep_etherpad-lite/node/hooks/express"
}
},
{
"name": "static",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/express/static"
}
},
{
"name": "stats",
"hooks": {
"shutdown": "ep_etherpad-lite/node/stats"
}
},
{
"name": "i18n",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/i18n"
}
},
{
"name": "specialpages",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/specialpages",
"expressPreSession": "ep_etherpad-lite/node/hooks/express/specialpages"
}
},
{
"name": "oauth2",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/security/OAuth2Provider"
}
},
{
"name": "padurlsanitize",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/padurlsanitize"
}
},
{
"name": "apicalls",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/express/apicalls"
}
},
{
"name": "importexport",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/importexport"
}
},
{
"name": "errorhandling",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/errorhandling"
}
},
{
"name": "socketio",
"hooks": {
"expressCloseServer": "ep_etherpad-lite/node/hooks/express/socketio",
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/socketio",
"socketio": "ep_etherpad-lite/node/handler/PadMessageHandler"
}
},
{
"name": "tests",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/express/tests"
}
},
{
"name": "admin",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/admin"
}
},
{
"name": "adminplugins",
"hooks": {
"socketio": "ep_etherpad-lite/node/hooks/express/adminplugins"
}
},
{
"name": "adminsettings",
"hooks": {
"socketio": "ep_etherpad-lite/node/hooks/express/adminsettings"
}
},
{
"name": "openapi",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/express/openapi"
}
}
]
}

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Fwolff",
"Naudefj"
]
"authors": ["Fwolff", "Naudefj"]
},
"index.newPad": "Nuwe pad",
"index.createOpenPad": "of skep/open 'n pad met die naam:",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Xuacu",
"YoaR"
]
"authors": ["Xuacu", "YoaR"]
},
"index.newPad": "Nuevu bloc",
"index.createOpenPad": "o crear/abrir un bloc col nome:",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"1AnuraagPandey",
"बडा काजी"
]
"authors": ["1AnuraagPandey", "बडा काजी"]
},
"index.newPad": "नयाँ प्याड",
"pad.toolbar.bold.title": "मोट (Ctrl-B)",

View file

@ -1,12 +1,6 @@
{
"@metadata": {
"authors": [
"Alp Er Tunqa",
"Amir a57",
"Ilğım",
"Koroğlu",
"Mousa"
]
"authors": ["Alp Er Tunqa", "Amir a57", "Ilğım", "Koroğlu", "Mousa"]
},
"index.newPad": "یئنی یادداشت دفترچه سی",
"index.createOpenPad": "یا دا ایجاد /بیر پد آدلا برابر آچماق:",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Baloch Afghanistan",
"Moshtank",
"Sultanselim baloch"
]
"authors": ["Baloch Afghanistan", "Moshtank", "Sultanselim baloch"]
},
"admin.page-title": "کارمسترءِ کُرسی - اترپَد",
"admin_plugins": "گݔشانکانءِ کار ءُ بار",

View file

@ -1,11 +1,6 @@
{
"@metadata": {
"authors": [
"Jim-by",
"Red Winged Duck",
"Renessaince",
"Wizardist"
]
"authors": ["Jim-by", "Red Winged Duck", "Renessaince", "Wizardist"]
},
"admin.page-title": "Адміністрацыйная панэль — Etherpad",
"admin_plugins": "Кіраўнік плагінаў",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"StanProg",
"Vlad5250",
"Vodnokon4e"
]
"authors": ["StanProg", "Vlad5250", "Vodnokon4e"]
},
"index.newPad": "Нов пад",
"index.createOpenPad": "или създаване/отваряне на пад с име:",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Baloch Afghanistan"
]
"authors": ["Baloch Afghanistan"]
},
"index.newPad": "یاداشتی نوکین کتابچه",
"index.createOpenPad": "یا جوڑ\t کورتین/پاچ کورتین یک کتابچه ئی یاداشتی بی نام:",

View file

@ -1,12 +1,6 @@
{
"@metadata": {
"authors": [
"Fohanno",
"Fulup",
"Gwenn-Ael",
"Huñvreüs",
"Y-M D"
]
"authors": ["Fohanno", "Fulup", "Gwenn-Ael", "Huñvreüs", "Y-M D"]
},
"index.newPad": "Pad nevez",
"index.createOpenPad": "pe krouiñ/digeriñ ur Pad gant an anv :",

View file

@ -1,11 +1,6 @@
{
"@metadata": {
"authors": [
"Edinwiki",
"Semina x",
"Srdjan m",
"Srđan"
]
"authors": ["Edinwiki", "Semina x", "Srdjan m", "Srđan"]
},
"index.newPad": "Novi Pad",
"index.createOpenPad": "ili napravite/otvorite Pad sa imenom:",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Michawiki"
]
"authors": ["Michawiki"]
},
"admin.page-title": "Administratorowa delka Etherpad",
"admin_plugins": "Zastojnik tykacow",

View file

@ -1,11 +1,6 @@
{
"@metadata": {
"authors": [
"Nirajan pant",
"बडा काजी",
"रमेश सिंह बोहरा",
"राम प्रसाद जोशी"
]
"authors": ["Nirajan pant", "बडा काजी", "रमेश सिंह बोहरा", "राम प्रसाद जोशी"]
},
"index.newPad": "नौलो प्याड",
"index.createOpenPad": "नाउँ सहितको नौलो प्याड सिर्जना गद्य्या / खोल्ल्या :",

View file

@ -1,187 +1,187 @@
{
"admin.page-title": "Admin Dashboard - Etherpad",
"admin_plugins": "Plugin manager",
"admin_plugins.available": "Available plugins",
"admin_plugins.available_not-found": "No plugins found.",
"admin_plugins.available_fetching": "Fetching…",
"admin_plugins.available_install.value": "Install",
"admin_plugins.available_search.placeholder": "Search for plugins to install",
"admin_plugins.description": "Description",
"admin_plugins.installed": "Installed plugins",
"admin_plugins.installed_fetching": "Fetching installed plugins…",
"admin_plugins.installed_nothing": "You haven't installed any plugins yet.",
"admin_plugins.installed_uninstall.value": "Uninstall",
"admin_plugins.last-update": "Last update",
"admin_plugins.name": "Name",
"admin_plugins.page-title": "Plugin manager - Etherpad",
"admin_plugins.version": "Version",
"admin_plugins_info": "Troubleshooting information",
"admin_plugins_info.hooks": "Installed hooks",
"admin_plugins_info.hooks_client": "Client-side hooks",
"admin_plugins_info.hooks_server": "Server-side hooks",
"admin_plugins_info.parts": "Installed parts",
"admin_plugins_info.plugins": "Installed plugins",
"admin_plugins_info.page-title": "Plugin information - Etherpad",
"admin_plugins_info.version": "Etherpad version",
"admin_plugins_info.version_latest": "Latest available version",
"admin_plugins_info.version_number": "Version number",
"admin_settings": "Settings",
"admin_settings.current": "Current configuration",
"admin_settings.current_example-devel": "Example development settings template",
"admin_settings.current_example-prod": "Example production settings template",
"admin_settings.current_restart.value": "Restart Etherpad",
"admin_settings.current_save.value": "Save Settings",
"admin_settings.page-title": "Settings - Etherpad",
"admin.page-title": "Admin Dashboard - Etherpad",
"admin_plugins": "Plugin manager",
"admin_plugins.available": "Available plugins",
"admin_plugins.available_not-found": "No plugins found.",
"admin_plugins.available_fetching": "Fetching…",
"admin_plugins.available_install.value": "Install",
"admin_plugins.available_search.placeholder": "Search for plugins to install",
"admin_plugins.description": "Description",
"admin_plugins.installed": "Installed plugins",
"admin_plugins.installed_fetching": "Fetching installed plugins…",
"admin_plugins.installed_nothing": "You haven't installed any plugins yet.",
"admin_plugins.installed_uninstall.value": "Uninstall",
"admin_plugins.last-update": "Last update",
"admin_plugins.name": "Name",
"admin_plugins.page-title": "Plugin manager - Etherpad",
"admin_plugins.version": "Version",
"admin_plugins_info": "Troubleshooting information",
"admin_plugins_info.hooks": "Installed hooks",
"admin_plugins_info.hooks_client": "Client-side hooks",
"admin_plugins_info.hooks_server": "Server-side hooks",
"admin_plugins_info.parts": "Installed parts",
"admin_plugins_info.plugins": "Installed plugins",
"admin_plugins_info.page-title": "Plugin information - Etherpad",
"admin_plugins_info.version": "Etherpad version",
"admin_plugins_info.version_latest": "Latest available version",
"admin_plugins_info.version_number": "Version number",
"admin_settings": "Settings",
"admin_settings.current": "Current configuration",
"admin_settings.current_example-devel": "Example development settings template",
"admin_settings.current_example-prod": "Example production settings template",
"admin_settings.current_restart.value": "Restart Etherpad",
"admin_settings.current_save.value": "Save Settings",
"admin_settings.page-title": "Settings - Etherpad",
"index.newPad": "New Pad",
"index.createOpenPad": "or create/open a Pad with the name:",
"index.openPad": "open an existing Pad with the name:",
"index.newPad": "New Pad",
"index.createOpenPad": "or create/open a Pad with the name:",
"index.openPad": "open an existing Pad with the name:",
"pad.toolbar.bold.title": "Bold (Ctrl+B)",
"pad.toolbar.italic.title": "Italic (Ctrl+I)",
"pad.toolbar.underline.title": "Underline (Ctrl+U)",
"pad.toolbar.strikethrough.title": "Strikethrough (Ctrl+5)",
"pad.toolbar.ol.title": "Ordered list (Ctrl+Shift+N)",
"pad.toolbar.ul.title": "Unordered List (Ctrl+Shift+L)",
"pad.toolbar.indent.title": "Indent (TAB)",
"pad.toolbar.unindent.title": "Outdent (Shift+TAB)",
"pad.toolbar.undo.title": "Undo (Ctrl+Z)",
"pad.toolbar.redo.title": "Redo (Ctrl+Y)",
"pad.toolbar.clearAuthorship.title": "Clear Authorship Colors (Ctrl+Shift+C)",
"pad.toolbar.import_export.title": "Import/Export from/to different file formats",
"pad.toolbar.timeslider.title": "Timeslider",
"pad.toolbar.savedRevision.title": "Save Revision",
"pad.toolbar.settings.title": "Settings",
"pad.toolbar.embed.title": "Share and Embed this pad",
"pad.toolbar.showusers.title": "Show the users on this pad",
"pad.toolbar.bold.title": "Bold (Ctrl+B)",
"pad.toolbar.italic.title": "Italic (Ctrl+I)",
"pad.toolbar.underline.title": "Underline (Ctrl+U)",
"pad.toolbar.strikethrough.title": "Strikethrough (Ctrl+5)",
"pad.toolbar.ol.title": "Ordered list (Ctrl+Shift+N)",
"pad.toolbar.ul.title": "Unordered List (Ctrl+Shift+L)",
"pad.toolbar.indent.title": "Indent (TAB)",
"pad.toolbar.unindent.title": "Outdent (Shift+TAB)",
"pad.toolbar.undo.title": "Undo (Ctrl+Z)",
"pad.toolbar.redo.title": "Redo (Ctrl+Y)",
"pad.toolbar.clearAuthorship.title": "Clear Authorship Colors (Ctrl+Shift+C)",
"pad.toolbar.import_export.title": "Import/Export from/to different file formats",
"pad.toolbar.timeslider.title": "Timeslider",
"pad.toolbar.savedRevision.title": "Save Revision",
"pad.toolbar.settings.title": "Settings",
"pad.toolbar.embed.title": "Share and Embed this pad",
"pad.toolbar.showusers.title": "Show the users on this pad",
"pad.colorpicker.save": "Save",
"pad.colorpicker.cancel": "Cancel",
"pad.colorpicker.save": "Save",
"pad.colorpicker.cancel": "Cancel",
"pad.loading": "Loading...",
"pad.noCookie": "Cookie could not be found. Please allow cookies in your browser! Your session and settings will not be saved between visits. This may be due to Etherpad being included in an iFrame in some Browsers. Please ensure Etherpad is on the same subdomain/domain as the parent iFrame",
"pad.permissionDenied": "You do not have permission to access this pad",
"pad.loading": "Loading...",
"pad.noCookie": "Cookie could not be found. Please allow cookies in your browser! Your session and settings will not be saved between visits. This may be due to Etherpad being included in an iFrame in some Browsers. Please ensure Etherpad is on the same subdomain/domain as the parent iFrame",
"pad.permissionDenied": "You do not have permission to access this pad",
"pad.settings.padSettings": "Pad Settings",
"pad.settings.myView": "My View",
"pad.settings.stickychat": "Chat always on screen",
"pad.settings.chatandusers": "Show Chat and Users",
"pad.settings.colorcheck": "Authorship colors",
"pad.settings.linenocheck": "Line numbers",
"pad.settings.rtlcheck": "Read content from right to left?",
"pad.settings.fontType": "Font type:",
"pad.settings.fontType.normal": "Normal",
"pad.settings.language": "Language:",
"pad.settings.about": "About",
"pad.settings.poweredBy": "Powered by",
"pad.settings.padSettings": "Pad Settings",
"pad.settings.myView": "My View",
"pad.settings.stickychat": "Chat always on screen",
"pad.settings.chatandusers": "Show Chat and Users",
"pad.settings.colorcheck": "Authorship colors",
"pad.settings.linenocheck": "Line numbers",
"pad.settings.rtlcheck": "Read content from right to left?",
"pad.settings.fontType": "Font type:",
"pad.settings.fontType.normal": "Normal",
"pad.settings.language": "Language:",
"pad.settings.about": "About",
"pad.settings.poweredBy": "Powered by",
"pad.importExport.import_export": "Import/Export",
"pad.importExport.import": "Upload any text file or document",
"pad.importExport.importSuccessful": "Successful!",
"pad.importExport.export": "Export current pad as:",
"pad.importExport.exportetherpad": "Etherpad",
"pad.importExport.exporthtml": "HTML",
"pad.importExport.exportplain": "Plain text",
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "You only can import from plain text or HTML formats. For more advanced import features please <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">install AbiWord or LibreOffice</a>.",
"pad.importExport.import_export": "Import/Export",
"pad.importExport.import": "Upload any text file or document",
"pad.importExport.importSuccessful": "Successful!",
"pad.importExport.export": "Export current pad as:",
"pad.importExport.exportetherpad": "Etherpad",
"pad.importExport.exporthtml": "HTML",
"pad.importExport.exportplain": "Plain text",
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "You only can import from plain text or HTML formats. For more advanced import features please <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">install AbiWord or LibreOffice</a>.",
"pad.modals.connected": "Connected.",
"pad.modals.reconnecting": "Reconnecting to your pad…",
"pad.modals.forcereconnect": "Force reconnect",
"pad.modals.reconnecttimer": "Trying to reconnect in",
"pad.modals.cancel": "Cancel",
"pad.modals.connected": "Connected.",
"pad.modals.reconnecting": "Reconnecting to your pad…",
"pad.modals.forcereconnect": "Force reconnect",
"pad.modals.reconnecttimer": "Trying to reconnect in",
"pad.modals.cancel": "Cancel",
"pad.modals.userdup": "Opened in another window",
"pad.modals.userdup.explanation": "This pad seems to be opened in more than one browser window on this computer.",
"pad.modals.userdup.advice": "Reconnect to use this window instead.",
"pad.modals.userdup": "Opened in another window",
"pad.modals.userdup.explanation": "This pad seems to be opened in more than one browser window on this computer.",
"pad.modals.userdup.advice": "Reconnect to use this window instead.",
"pad.modals.unauth": "Not authorized",
"pad.modals.unauth.explanation": "Your permissions have changed while viewing this page. Try to reconnect.",
"pad.modals.unauth": "Not authorized",
"pad.modals.unauth.explanation": "Your permissions have changed while viewing this page. Try to reconnect.",
"pad.modals.looping.explanation": "There are communication problems with the synchronization server.",
"pad.modals.looping.cause": "Perhaps you connected through an incompatible firewall or proxy.",
"pad.modals.looping.explanation": "There are communication problems with the synchronization server.",
"pad.modals.looping.cause": "Perhaps you connected through an incompatible firewall or proxy.",
"pad.modals.initsocketfail": "Server is unreachable.",
"pad.modals.initsocketfail.explanation": "Couldn't connect to the synchronization server.",
"pad.modals.initsocketfail.cause": "This is probably due to a problem with your browser or your internet connection.",
"pad.modals.initsocketfail": "Server is unreachable.",
"pad.modals.initsocketfail.explanation": "Couldn't connect to the synchronization server.",
"pad.modals.initsocketfail.cause": "This is probably due to a problem with your browser or your internet connection.",
"pad.modals.slowcommit.explanation": "The server is not responding.",
"pad.modals.slowcommit.cause": "This could be due to problems with network connectivity.",
"pad.modals.slowcommit.explanation": "The server is not responding.",
"pad.modals.slowcommit.cause": "This could be due to problems with network connectivity.",
"pad.modals.badChangeset.explanation": "An edit you have made was classified illegal by the synchronization server.",
"pad.modals.badChangeset.cause": "This could be due to a wrong server configuration or some other unexpected behavior. Please contact the service administrator, if you feel this is an error. Try to reconnect in order to continue editing.",
"pad.modals.badChangeset.explanation": "An edit you have made was classified illegal by the synchronization server.",
"pad.modals.badChangeset.cause": "This could be due to a wrong server configuration or some other unexpected behavior. Please contact the service administrator, if you feel this is an error. Try to reconnect in order to continue editing.",
"pad.modals.corruptPad.explanation": "The pad you are trying to access is corrupt.",
"pad.modals.corruptPad.cause": "This may be due to a wrong server configuration or some other unexpected behavior. Please contact the service administrator.",
"pad.modals.corruptPad.explanation": "The pad you are trying to access is corrupt.",
"pad.modals.corruptPad.cause": "This may be due to a wrong server configuration or some other unexpected behavior. Please contact the service administrator.",
"pad.modals.deleted": "Deleted.",
"pad.modals.deleted.explanation": "This pad has been removed.",
"pad.modals.deleted": "Deleted.",
"pad.modals.deleted.explanation": "This pad has been removed.",
"pad.modals.rateLimited": "Rate Limited.",
"pad.modals.rateLimited.explanation": "You sent too many messages to this pad so it disconnected you.",
"pad.modals.rateLimited": "Rate Limited.",
"pad.modals.rateLimited.explanation": "You sent too many messages to this pad so it disconnected you.",
"pad.modals.rejected.explanation": "The server rejected a message that was sent by your browser.",
"pad.modals.rejected.cause": "The server may have been updated while you were viewing the pad, or maybe there is a bug in Etherpad. Try reloading the page.",
"pad.modals.rejected.explanation": "The server rejected a message that was sent by your browser.",
"pad.modals.rejected.cause": "The server may have been updated while you were viewing the pad, or maybe there is a bug in Etherpad. Try reloading the page.",
"pad.modals.disconnected": "You have been disconnected.",
"pad.modals.disconnected.explanation": "The connection to the server was lost",
"pad.modals.disconnected.cause": "The server may be unavailable. Please notify the service administrator if this continues to happen.",
"pad.modals.disconnected": "You have been disconnected.",
"pad.modals.disconnected.explanation": "The connection to the server was lost",
"pad.modals.disconnected.cause": "The server may be unavailable. Please notify the service administrator if this continues to happen.",
"pad.share": "Share this pad",
"pad.share.readonly": "Read only",
"pad.share.link": "Link",
"pad.share.emebdcode": "Embed URL",
"pad.chat": "Chat",
"pad.chat.title": "Open the chat for this pad.",
"pad.chat.loadmessages": "Load more messages",
"pad.chat.stick.title": "Stick chat to screen",
"pad.chat.writeMessage.placeholder": "Write your message here",
"pad.share": "Share this pad",
"pad.share.readonly": "Read only",
"pad.share.link": "Link",
"pad.share.emebdcode": "Embed URL",
"pad.chat": "Chat",
"pad.chat.title": "Open the chat for this pad.",
"pad.chat.loadmessages": "Load more messages",
"pad.chat.stick.title": "Stick chat to screen",
"pad.chat.writeMessage.placeholder": "Write your message here",
"timeslider.followContents": "Follow pad content updates",
"timeslider.pageTitle": "{{appTitle}} Timeslider",
"timeslider.toolbar.returnbutton": "Return to pad",
"timeslider.toolbar.authors": "Authors:",
"timeslider.toolbar.authorsList": "No Authors",
"timeslider.toolbar.exportlink.title": "Export",
"timeslider.exportCurrent": "Export current version as:",
"timeslider.version": "Version {{version}}",
"timeslider.saved": "Saved {{month}} {{day}}, {{year}}",
"timeslider.followContents": "Follow pad content updates",
"timeslider.pageTitle": "{{appTitle}} Timeslider",
"timeslider.toolbar.returnbutton": "Return to pad",
"timeslider.toolbar.authors": "Authors:",
"timeslider.toolbar.authorsList": "No Authors",
"timeslider.toolbar.exportlink.title": "Export",
"timeslider.exportCurrent": "Export current version as:",
"timeslider.version": "Version {{version}}",
"timeslider.saved": "Saved {{month}} {{day}}, {{year}}",
"timeslider.playPause": "Playback / Pause Pad Contents",
"timeslider.backRevision":"Go back a revision in this Pad",
"timeslider.forwardRevision":"Go forward a revision in this Pad",
"timeslider.playPause": "Playback / Pause Pad Contents",
"timeslider.backRevision": "Go back a revision in this Pad",
"timeslider.forwardRevision": "Go forward a revision in this Pad",
"timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}",
"timeslider.month.january": "January",
"timeslider.month.february": "February",
"timeslider.month.march": "March",
"timeslider.month.april": "April",
"timeslider.month.may": "May",
"timeslider.month.june": "June",
"timeslider.month.july": "July",
"timeslider.month.august": "August",
"timeslider.month.september": "September",
"timeslider.month.october": "October",
"timeslider.month.november": "November",
"timeslider.month.december": "December",
"timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}",
"timeslider.month.january": "January",
"timeslider.month.february": "February",
"timeslider.month.march": "March",
"timeslider.month.april": "April",
"timeslider.month.may": "May",
"timeslider.month.june": "June",
"timeslider.month.july": "July",
"timeslider.month.august": "August",
"timeslider.month.september": "September",
"timeslider.month.october": "October",
"timeslider.month.november": "November",
"timeslider.month.december": "December",
"timeslider.unnamedauthors": "{{num}} unnamed {[plural(num) one: author, other: authors ]}",
"pad.savedrevs.marked": "This revision is now marked as a saved revision",
"pad.savedrevs.timeslider": "You can see saved revisions by visiting the timeslider",
"pad.userlist.entername": "Enter your name",
"pad.userlist.unnamed": "unnamed",
"pad.editbar.clearcolors": "Clear authorship colors on entire document? This cannot be undone",
"timeslider.unnamedauthors": "{{num}} unnamed {[plural(num) one: author, other: authors ]}",
"pad.savedrevs.marked": "This revision is now marked as a saved revision",
"pad.savedrevs.timeslider": "You can see saved revisions by visiting the timeslider",
"pad.userlist.entername": "Enter your name",
"pad.userlist.unnamed": "unnamed",
"pad.editbar.clearcolors": "Clear authorship colors on entire document? This cannot be undone",
"pad.impexp.importbutton": "Import Now",
"pad.impexp.importing": "Importing...",
"pad.impexp.confirmimport": "Importing a file will overwrite the current text of the pad. Are you sure you want to proceed?",
"pad.impexp.convertFailed": "We were not able to import this file. Please use a different document format or copy paste manually",
"pad.impexp.padHasData": "We were not able to import this file because this Pad has already had changes, please import to a new pad",
"pad.impexp.uploadFailed": "The upload failed, please try again",
"pad.impexp.importfailed": "Import failed",
"pad.impexp.copypaste": "Please copy paste",
"pad.impexp.exportdisabled": "Exporting as {{type}} format is disabled. Please contact your system administrator for details.",
"pad.impexp.maxFileSize": "File too big. Contact your site administrator to increase the allowed file size for import"
"pad.impexp.importbutton": "Import Now",
"pad.impexp.importing": "Importing...",
"pad.impexp.confirmimport": "Importing a file will overwrite the current text of the pad. Are you sure you want to proceed?",
"pad.impexp.convertFailed": "We were not able to import this file. Please use a different document format or copy paste manually",
"pad.impexp.padHasData": "We were not able to import this file because this Pad has already had changes, please import to a new pad",
"pad.impexp.uploadFailed": "The upload failed, please try again",
"pad.impexp.importfailed": "Import failed",
"pad.impexp.copypaste": "Please copy paste",
"pad.impexp.exportdisabled": "Exporting as {{type}} format is disabled. Please contact your system administrator for details.",
"pad.impexp.maxFileSize": "File too big. Contact your site administrator to increase the allowed file size for import"
}

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Kristian.kankainen",
"Tiblu"
]
"authors": ["Kristian.kankainen", "Tiblu"]
},
"index.newPad": "Uus klade",
"index.createOpenPad": "loo või rööptoimeta kladet nimega:",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Ibrahima Malal Sarr"
]
"authors": ["Ibrahima Malal Sarr"]
},
"admin.page-title": "Tiimtorde Jiiloowo - Etherpad",
"admin_plugins": "Toppitorde Ceŋe",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"EileenSanda"
]
"authors": ["EileenSanda"]
},
"index.newPad": "Nýggjur teldil",
"pad.toolbar.bold.title": "Við feitum (Ctrl-B)",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Robin van der Vliet"
]
"authors": ["Robin van der Vliet"]
},
"pad.toolbar.bold.title": "Fet (Ctrl+B)",
"pad.toolbar.italic.title": "Kursyf (Ctrl+I)",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Elisardojm",
"Ghose",
"Toliño"
]
"authors": ["Elisardojm", "Ghose", "Toliño"]
},
"admin.page-title": "Panel de administración - Etherpad",
"admin_plugins": "Xestor de complementos",

View file

@ -1,11 +1,6 @@
{
"@metadata": {
"authors": [
"Bhatakati aatma",
"Dsvyas",
"Harsh4101991",
"KartikMistry"
]
"authors": ["Bhatakati aatma", "Dsvyas", "Harsh4101991", "KartikMistry"]
},
"index.newPad": "નવું પેડ",
"pad.toolbar.bold.title": "બોલ્ડ",

View file

@ -1,12 +1,6 @@
{
"@metadata": {
"authors": [
"Amire80",
"Ofrahod",
"Steeve815",
"YaronSh",
"תומר ט"
]
"authors": ["Amire80", "Ofrahod", "Steeve815", "YaronSh", "תומר ט"]
},
"admin.page-title": "לוח ניהול - Etherpad",
"admin_plugins": "מנהל תוספים",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Sfic"
]
"authors": ["Sfic"]
},
"pad.toolbar.bold.title": "गहरा (Ctrl+B)",
"pad.toolbar.italic.title": "तिरछा (Ctrl+I)",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Bugoslav",
"Hmxhmx",
"Ponor"
]
"authors": ["Bugoslav", "Hmxhmx", "Ponor"]
},
"index.newPad": "Novi blokić",
"index.createOpenPad": "ili stvori/otvori blokić s imenom:",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Paul Beppler"
]
"authors": ["Paul Beppler"]
},
"index.newPad": "Neies Pad",
"index.createOpenPad": "Pad mit follichendem Noome uffmache:",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Michawiki"
]
"authors": ["Michawiki"]
},
"admin.page-title": "Administratorowa deska Etherpad",
"admin_plugins": "Zrjadowak tykačow",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Armenoid",
"Kareyac"
]
"authors": ["Armenoid", "Kareyac"]
},
"admin_plugins.available_install.value": "Տեղադրել",
"admin_plugins.description": "Նկարագրություն",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"McDutchie"
]
"authors": ["McDutchie"]
},
"admin.page-title": "Pannello administrative Etherpad",
"admin_plugins": "Gestor de plug-ins",

View file

@ -1,11 +1,6 @@
{
"@metadata": {
"authors": [
"Bennylin",
"IvanLanin",
"Marwan Mohamad",
"Veracious"
]
"authors": ["Bennylin", "IvanLanin", "Marwan Mohamad", "Veracious"]
},
"admin.page-title": "Dasbor Pengurus - Etherpad",
"admin_plugins": "Manajer plugin",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Sveinki",
"Sveinn í Felli"
]
"authors": ["Sveinki", "Sveinn í Felli"]
},
"admin.page-title": "Stjórnborð fyrir stjórnendur - Etherpad",
"admin_plugins": "Stýring viðbóta",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Belkacem77"
]
"authors": ["Belkacem77"]
},
"index.newPad": "Apad amaynut",
"index.createOpenPad": "neɣ rnu/ldi apad s yisem:",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Pichnat Thong",
"Sovichet",
"វ័ណថារិទ្ធ"
]
"authors": ["Pichnat Thong", "Sovichet", "វ័ណថារិទ្ធ"]
},
"index.newPad": "ផេតថ្មី",
"index.createOpenPad": "ឬបង្កើត/បើកផេតដែលមានឈ្មោះ៖",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Nayvik",
"ಮಲ್ನಾಡಾಚ್ ಕೊಂಕ್ಣೊ"
]
"authors": ["Nayvik", "ಮಲ್ನಾಡಾಚ್ ಕೊಂಕ್ಣೊ"]
},
"admin_plugins.available": "ಲಭ್ಯವಿರುವ ಪ್ಲಗಿನ್‌ಗಳು",
"admin_plugins.available_not-found": "ಯಾವುದೇ ಪ್ಲಗಿನ್‌ಗಳು ಸಿಗಲಿಲ್ಲ",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Ernác",
"Къарачайлы"
]
"authors": ["Ernác", "Къарачайлы"]
},
"admin.page-title": "Администраторну панели — Etherpad",
"admin_plugins": "Плагин менеджер",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Purodha"
]
"authors": ["Purodha"]
},
"index.newPad": "Neu Pädd",
"index.createOpenPad": "udder maach e Pädd op med däm Nahme:",

View file

@ -1,11 +1,6 @@
{
"@metadata": {
"authors": [
"Gromper",
"Robby",
"Soued031",
"Volvox"
]
"authors": ["Gromper", "Robby", "Soued031", "Volvox"]
},
"admin_plugins.available_install.value": "Installéieren",
"admin_plugins.description": "Beschreiwung",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Arash71",
"Hosseinblue",
"Lakzon"
]
"authors": ["Arash71", "Hosseinblue", "Lakzon"]
},
"index.newPad": ازۀpad",
"index.createOpenPad": ":وە نۆم Pad یا سازین/واز کردن یإگلە",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Lorestani",
"Mogoeilor"
]
"authors": ["Lorestani", "Mogoeilor"]
},
"index.newPad": "دٱفتٱرچٱ تازٱ",
"pad.toolbar.bold.title": "تۊپور",

View file

@ -1,12 +1,6 @@
{
"@metadata": {
"authors": [
"Admresdeserv.",
"Jmg.cmdi",
"Oskars",
"Papuass",
"Silraks"
]
"authors": ["Admresdeserv.", "Jmg.cmdi", "Oskars", "Papuass", "Silraks"]
},
"index.newPad": "Tiek izmantots kā pogas teksts. Blociņš, Etherpad kontekstā, ir piezīmju blociņš, uz kura rakstīt.",
"index.createOpenPad": "vai izveidojiet/atveriet Blociņu ar nosaukumu:",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Empu",
"StefanusRA"
]
"authors": ["Empu", "StefanusRA"]
},
"index.newPad": "Pad Anyar",
"index.createOpenPad": "utawa gawe/bukak Pad nganggo jeneng:",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Jagwar"
]
"authors": ["Jagwar"]
},
"index.newPad": "Pad vaovao",
"index.createOpenPad": "na hamorona/hanokatra Pad manana anarana:",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Bjankuloski06",
"Brest",
"Vlad5250"
]
"authors": ["Bjankuloski06", "Brest", "Vlad5250"]
},
"admin.page-title": "Администраторска управувачница — Etherpad",
"admin_plugins": "Раководител со приклучоци",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"MongolWiki",
"Munkhzaya.E",
"Wisdom"
]
"authors": ["MongolWiki", "Munkhzaya.E", "Wisdom"]
},
"pad.toolbar.bold.title": "Болд тескт (Ctrl-B)",
"pad.toolbar.italic.title": "Налуу тескт (Ctrl-I)",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Aue Nai",
"咽頭べさ"
]
"authors": ["Aue Nai", "咽頭べさ"]
},
"admin_plugins.description": "တၚ်ထမံက်ထ္ၜး",
"index.newPad": "တၞးတၟိ",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Ganeshgiram",
"V.narsikar",
"Ydyashad"
]
"authors": ["Ganeshgiram", "V.narsikar", "Ydyashad"]
},
"index.newPad": "नव पान",
"pad.toolbar.bold.title": "ठळक (Ctrl-B)",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Anakmalaysia",
"Hakimi97",
"Jeluang Terluang"
]
"authors": ["Anakmalaysia", "Hakimi97", "Jeluang Terluang"]
},
"admin.page-title": "Papan muka Penyelia - Etherpad",
"index.newPad": "Pad baru",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Andibecker",
"Dr Lotus Black"
]
"authors": ["Andibecker", "Dr Lotus Black"]
},
"admin.page-title": "စီမံခန့်ခွဲသူဒိုင်ခွက် - Etherpad",
"admin_plugins": "ပလပ်အင်မန်နေဂျာ",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Akapochtli",
"Languaeditor",
"Taresi"
]
"authors": ["Akapochtli", "Languaeditor", "Taresi"]
},
"index.newPad": "Yancuic Pad",
"index.createOpenPad": "auh xicchīhua/xictlapo cē Pad in ītōcā:",

View file

@ -1,11 +1,6 @@
{
"@metadata": {
"authors": [
"C.R.",
"Chelin",
"Finizio",
"Ruthven"
]
"authors": ["C.R.", "Chelin", "Finizio", "Ruthven"]
},
"admin_plugins.name": "Nomme",
"index.newPad": "Nuovo Pad",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Gthoele",
"Joachim Mos"
]
"authors": ["Gthoele", "Joachim Mos"]
},
"index.newPad": "Nee'et Pad",
"index.createOpenPad": "oder Pad mit düssen Naam apen maken:",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Unhammer"
]
"authors": ["Unhammer"]
},
"index.newPad": "Ny blokk",
"index.createOpenPad": "eller opprett/opna ei blokk med namnet:",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Cedric31",
"Quentí"
]
"authors": ["Cedric31", "Quentí"]
},
"admin.page-title": "Panèl dadministracion - Etherpad",
"admin_plugins": "Gestion de las extensions",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Denö",
"Ilja.mos",
"Mashoi7"
]
"authors": ["Denö", "Ilja.mos", "Mashoi7"]
},
"pad.toolbar.underline.title": "Alleviivua (Ctrl+U)",
"pad.toolbar.settings.title": "Azetukset",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Bouron"
]
"authors": ["Bouron"]
},
"index.newPad": "Ног",
"index.createOpenPad": "кӕнӕ сараз/бакӕн ног документ ахӕм номимӕ:",

View file

@ -1,12 +1,6 @@
{
"@metadata": {
"authors": [
"Aalam",
"Babanwalia",
"Tow",
"ਗੁਰਪ੍ਰੀਤ ਹੁੰਦਲ",
"ਪ੍ਰਚਾਰਕ"
]
"authors": ["Aalam", "Babanwalia", "Tow", "ਗੁਰਪ੍ਰੀਤ ਹੁੰਦਲ", "ਪ੍ਰਚਾਰਕ"]
},
"index.newPad": "ਨਵਾਂ ਪੈਡ",
"index.createOpenPad": "ਜਾਂ ਨਾਂ ਨਾਲ ਨਵਾਂ ਪੈਡ ਬਣਾਓ/ਖੋਲ੍ਹੋ:",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Borichèt"
]
"authors": ["Borichèt"]
},
"admin.page-title": "Cruscòt d'aministrator - Etherpad",
"admin_plugins": "Mansé dj'anstalassion",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Ahmed-Najib-Biabani-Ibrahimkhel"
]
"authors": ["Ahmed-Najib-Biabani-Ibrahimkhel"]
},
"index.newPad": "نوې ليکچه",
"index.createOpenPad": "يا په همدې نوم يوه نوې ليکچه جوړول/پرانيستل:",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Adr mm"
]
"authors": ["Adr mm"]
},
"admin.page-title": "Pannellu de amministratzione - Etherpad",
"admin_plugins": "Gestore de connetores",

View file

@ -1,11 +1,6 @@
{
"@metadata": {
"authors": [
"BaRaN6161 TURK",
"Kaleem Bhatti",
"Mehtab ahmed",
"Tweety"
]
"authors": ["BaRaN6161 TURK", "Kaleem Bhatti", "Mehtab ahmed", "Tweety"]
},
"admin_settings": "ترتيبون",
"index.newPad": "نئين پٽي",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Conquistador",
"Vlad5250"
]
"authors": ["Conquistador", "Vlad5250"]
},
"admin_plugins.available_not-found": "Nijedan plugin nije pronađen.",
"admin_plugins.description": "Opis",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Ninjastrikers",
"Saimawnkham",
"Saosukham"
]
"authors": ["Ninjastrikers", "Saimawnkham", "Saosukham"]
},
"index.newPad": "ၽႅတ်ႉမႂ်ႇ",
"index.createOpenPad": "ဢမ်ႇၼၼ် ၶူင်မႂ်ႇ/ပိုတ်ႇၽႅတ်ႉၵိုၵ်းၸိုဝ်ႈ",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Saraiki"
]
"authors": ["Saraiki"]
},
"admin_plugins": "پلگ ان منیجر",
"admin_plugins.available": "دستیاب پلگ ان",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Yupik"
]
"authors": ["Yupik"]
},
"admin_plugins.description": "Deskriptt",
"admin_plugins.name": "Nõmm",

View file

@ -1,11 +1,6 @@
{
"@metadata": {
"authors": [
"Besnik b",
"Eraldkerciku",
"Kosovastar",
"Liridon"
]
"authors": ["Besnik b", "Eraldkerciku", "Kosovastar", "Liridon"]
},
"admin.page-title": "Pult Përgjegjësi - Etherpad",
"admin_plugins": "Përgjegjës shtojcash",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"Adr mm",
"F Samaritani"
]
"authors": ["Adr mm", "F Samaritani"]
},
"admin.page-title": "Pannellu amministrativu - Etherpad",
"admin_plugins": "Gestore de connetores",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Andibecker",
"Edwingudfriend",
"Muddyb"
]
"authors": ["Andibecker", "Edwingudfriend", "Muddyb"]
},
"admin.page-title": "Dashibodi ya Usimamizi - Etherpad",
"admin_plugins": "Meneja wa programu-jalizi",

View file

@ -1,10 +1,6 @@
{
"@metadata": {
"authors": [
"Balajijagadesh",
"ElangoRamanujam",
"Sank"
]
"authors": ["Balajijagadesh", "ElangoRamanujam", "Sank"]
},
"index.newPad": "புதிய அட்டை",
"index.createOpenPad": "அல்லது பெயருடன் ஒரு அட்டையை உருவாக்கு/திற",

View file

@ -1,9 +1,6 @@
{
"@metadata": {
"authors": [
"BHARATHESHA ALASANDEMAJALU",
"VASANTH S.N."
]
"authors": ["BHARATHESHA ALASANDEMAJALU", "VASANTH S.N."]
},
"index.newPad": "ಪೊಸ ಪ್ಯಾಡ್",
"index.createOpenPad": "ಅತಂಡ ಈ ಪುದರ್ತ ಪ್ಯಾಡನ್ನು ಉಂಡು ಮನ್ಪು/ತೋಜಾಲ:",

View file

@ -1,11 +1,6 @@
{
"@metadata": {
"authors": [
"Aefgh39622",
"Andibecker",
"Patsagorn Y.",
"Trisorn Triboon"
]
"authors": ["Aefgh39622", "Andibecker", "Patsagorn Y.", "Trisorn Triboon"]
},
"admin.page-title": "แดชบอร์ดผู้ดูแลระบบ - Etherpad",
"admin_plugins": "ตัวจัดการปลั๊กอิน",

View file

@ -1,8 +1,6 @@
{
"@metadata": {
"authors": [
"Fierodelveneto"
]
"authors": ["Fierodelveneto"]
},
"index.newPad": "Novo Pad",
"index.createOpenPad": "O creare o verxare on Pad co'l nome:",

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
'use strict';
/**
* The AuthorManager controlls all information about the Pad authors
*/
@ -19,76 +18,79 @@
* limitations under the License.
*/
const db = require('./DB');
const CustomError = require('../utils/customError');
const hooks = require('../../static/js/pluginfw/hooks.js');
const {randomString, padutils: {warnDeprecated}} = require('../../static/js/pad_utils');
const db = require("./DB");
const CustomError = require("../utils/customError");
const hooks = require("../../static/js/pluginfw/hooks.js");
const {
randomString,
padutils: { warnDeprecated },
} = require("../../static/js/pad_utils");
exports.getColorPalette = () => [
'#ffc7c7',
'#fff1c7',
'#e3ffc7',
'#c7ffd5',
'#c7ffff',
'#c7d5ff',
'#e3c7ff',
'#ffc7f1',
'#ffa8a8',
'#ffe699',
'#cfff9e',
'#99ffb3',
'#a3ffff',
'#99b3ff',
'#cc99ff',
'#ff99e5',
'#e7b1b1',
'#e9dcAf',
'#cde9af',
'#bfedcc',
'#b1e7e7',
'#c3cdee',
'#d2b8ea',
'#eec3e6',
'#e9cece',
'#e7e0ca',
'#d3e5c7',
'#bce1c5',
'#c1e2e2',
'#c1c9e2',
'#cfc1e2',
'#e0bdd9',
'#baded3',
'#a0f8eb',
'#b1e7e0',
'#c3c8e4',
'#cec5e2',
'#b1d5e7',
'#cda8f0',
'#f0f0a8',
'#f2f2a6',
'#f5a8eb',
'#c5f9a9',
'#ececbb',
'#e7c4bc',
'#daf0b2',
'#b0a0fd',
'#bce2e7',
'#cce2bb',
'#ec9afe',
'#edabbd',
'#aeaeea',
'#c4e7b1',
'#d722bb',
'#f3a5e7',
'#ffa8a8',
'#d8c0c5',
'#eaaedd',
'#adc6eb',
'#bedad1',
'#dee9af',
'#e9afc2',
'#f8d2a0',
'#b3b3e6',
"#ffc7c7",
"#fff1c7",
"#e3ffc7",
"#c7ffd5",
"#c7ffff",
"#c7d5ff",
"#e3c7ff",
"#ffc7f1",
"#ffa8a8",
"#ffe699",
"#cfff9e",
"#99ffb3",
"#a3ffff",
"#99b3ff",
"#cc99ff",
"#ff99e5",
"#e7b1b1",
"#e9dcAf",
"#cde9af",
"#bfedcc",
"#b1e7e7",
"#c3cdee",
"#d2b8ea",
"#eec3e6",
"#e9cece",
"#e7e0ca",
"#d3e5c7",
"#bce1c5",
"#c1e2e2",
"#c1c9e2",
"#cfc1e2",
"#e0bdd9",
"#baded3",
"#a0f8eb",
"#b1e7e0",
"#c3c8e4",
"#cec5e2",
"#b1d5e7",
"#cda8f0",
"#f0f0a8",
"#f2f2a6",
"#f5a8eb",
"#c5f9a9",
"#ececbb",
"#e7c4bc",
"#daf0b2",
"#b0a0fd",
"#bce2e7",
"#cce2bb",
"#ec9afe",
"#edabbd",
"#aeaeea",
"#c4e7b1",
"#d722bb",
"#f3a5e7",
"#ffa8a8",
"#d8c0c5",
"#eaaedd",
"#adc6eb",
"#bedad1",
"#dee9af",
"#e9afc2",
"#f8d2a0",
"#b3b3e6",
];
/**
@ -96,9 +98,9 @@ exports.getColorPalette = () => [
* @param {String} authorID The id of the author
*/
exports.doesAuthorExist = async (authorID: string) => {
const author = await db.get(`globalAuthor:${authorID}`);
const author = await db.get(`globalAuthor:${authorID}`);
return author != null;
return author != null;
};
/**
@ -107,34 +109,33 @@ exports.doesAuthorExist = async (authorID: string) => {
*/
exports.doesAuthorExists = exports.doesAuthorExist;
/**
* Returns the AuthorID for a mapper. We can map using a mapperkey,
* so far this is token2author and mapper2author
* @param {String} mapperkey The database key name for this mapper
* @param {String} mapper The mapper
*/
const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => {
// try to map to an author
const author = await db.get(`${mapperkey}:${mapper}`);
const mapAuthorWithDBKey = async (mapperkey: string, mapper: string) => {
// try to map to an author
const author = await db.get(`${mapperkey}:${mapper}`);
if (author == null) {
// there is no author with this mapper, so create one
const author = await exports.createAuthor(null);
if (author == null) {
// there is no author with this mapper, so create one
const author = await exports.createAuthor(null);
// create the token2author relation
await db.set(`${mapperkey}:${mapper}`, author.authorID);
// create the token2author relation
await db.set(`${mapperkey}:${mapper}`, author.authorID);
// return the author
return author;
}
// return the author
return author;
}
// there is an author with this mapper
// update the timestamp of this author
await db.setSub(`globalAuthor:${author}`, ['timestamp'], Date.now());
// there is an author with this mapper
// update the timestamp of this author
await db.setSub(`globalAuthor:${author}`, ["timestamp"], Date.now());
// return the author
return {authorID: author};
// return the author
return { authorID: author };
};
/**
@ -143,10 +144,10 @@ const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => {
* @return {Promise<string|*|{authorID: string}|{authorID: *}>}
*/
const getAuthor4Token = async (token: string) => {
const author = await mapAuthorWithDBKey('token2author', token);
const author = await mapAuthorWithDBKey("token2author", token);
// return only the sub value authorID
return author ? author.authorID : author;
// return only the sub value authorID
return author ? author.authorID : author;
};
/**
@ -156,10 +157,10 @@ const getAuthor4Token = async (token: string) => {
* @return {Promise<*>}
*/
exports.getAuthorId = async (token: string, user: object) => {
const context = {dbKey: token, token, user};
let [authorId] = await hooks.aCallFirst('getAuthorId', context);
if (!authorId) authorId = await getAuthor4Token(context.dbKey);
return authorId;
const context = { dbKey: token, token, user };
let [authorId] = await hooks.aCallFirst("getAuthorId", context);
if (!authorId) authorId = await getAuthor4Token(context.dbKey);
return authorId;
};
/**
@ -169,9 +170,10 @@ exports.getAuthorId = async (token: string, user: object) => {
* @param {String} token The token
*/
exports.getAuthor4Token = async (token: string) => {
warnDeprecated(
'AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead');
return await getAuthor4Token(token);
warnDeprecated(
"AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead",
);
return await getAuthor4Token(token);
};
/**
@ -179,95 +181,100 @@ exports.getAuthor4Token = async (token: string) => {
* @param {String} authorMapper The mapper
* @param {String} name The name of the author (optional)
*/
exports.createAuthorIfNotExistsFor = async (authorMapper: string, name: string) => {
const author = await mapAuthorWithDBKey('mapper2author', authorMapper);
exports.createAuthorIfNotExistsFor = async (
authorMapper: string,
name: string,
) => {
const author = await mapAuthorWithDBKey("mapper2author", authorMapper);
if (name) {
// set the name of this author
await exports.setAuthorName(author.authorID, name);
}
if (name) {
// set the name of this author
await exports.setAuthorName(author.authorID, name);
}
return author;
return author;
};
/**
* Internal function that creates the database entry for an author
* @param {String} name The name of the author
*/
exports.createAuthor = async (name: string) => {
// create the new author name
const author = `a.${randomString(16)}`;
// create the new author name
const author = `a.${randomString(16)}`;
// create the globalAuthors db entry
const authorObj = {
colorId: Math.floor(Math.random() * (exports.getColorPalette().length)),
name,
timestamp: Date.now(),
};
// create the globalAuthors db entry
const authorObj = {
colorId: Math.floor(Math.random() * exports.getColorPalette().length),
name,
timestamp: Date.now(),
};
// set the global author db entry
await db.set(`globalAuthor:${author}`, authorObj);
// set the global author db entry
await db.set(`globalAuthor:${author}`, authorObj);
return {authorID: author};
return { authorID: author };
};
/**
* Returns the Author Obj of the author
* @param {String} author The id of the author
*/
exports.getAuthor = async (author: string) => await db.get(`globalAuthor:${author}`);
exports.getAuthor = async (author: string) =>
await db.get(`globalAuthor:${author}`);
/**
* Returns the color Id of the author
* @param {String} author The id of the author
*/
exports.getAuthorColorId = async (author: string) => await db.getSub(`globalAuthor:${author}`, ['colorId']);
exports.getAuthorColorId = async (author: string) =>
await db.getSub(`globalAuthor:${author}`, ["colorId"]);
/**
* Sets the color Id of the author
* @param {String} author The id of the author
* @param {String} colorId The color id of the author
*/
exports.setAuthorColorId = async (author: string, colorId: string) => await db.setSub(
`globalAuthor:${author}`, ['colorId'], colorId);
exports.setAuthorColorId = async (author: string, colorId: string) =>
await db.setSub(`globalAuthor:${author}`, ["colorId"], colorId);
/**
* Returns the name of the author
* @param {String} author The id of the author
*/
exports.getAuthorName = async (author: string) => await db.getSub(`globalAuthor:${author}`, ['name']);
exports.getAuthorName = async (author: string) =>
await db.getSub(`globalAuthor:${author}`, ["name"]);
/**
* Sets the name of the author
* @param {String} author The id of the author
* @param {String} name The name of the author
*/
exports.setAuthorName = async (author: string, name: string) => await db.setSub(
`globalAuthor:${author}`, ['name'], name);
exports.setAuthorName = async (author: string, name: string) =>
await db.setSub(`globalAuthor:${author}`, ["name"], name);
/**
* Returns an array of all pads this author contributed to
* @param {String} authorID The id of the author
*/
exports.listPadsOfAuthor = async (authorID: string) => {
/* There are two other places where this array is manipulated:
* (1) When the author is added to a pad, the author object is also updated
* (2) When a pad is deleted, each author of that pad is also updated
*/
/* There are two other places where this array is manipulated:
* (1) When the author is added to a pad, the author object is also updated
* (2) When a pad is deleted, each author of that pad is also updated
*/
// get the globalAuthor
const author = await db.get(`globalAuthor:${authorID}`);
// get the globalAuthor
const author = await db.get(`globalAuthor:${authorID}`);
if (author == null) {
// author does not exist
throw new CustomError('authorID does not exist', 'apierror');
}
if (author == null) {
// author does not exist
throw new CustomError("authorID does not exist", "apierror");
}
// everything is fine, return the pad IDs
const padIDs = Object.keys(author.padIDs || {});
// everything is fine, return the pad IDs
const padIDs = Object.keys(author.padIDs || {});
return {padIDs};
return { padIDs };
};
/**
@ -276,25 +283,25 @@ exports.listPadsOfAuthor = async (authorID: string) => {
* @param {String} padID The id of the pad the author contributes to
*/
exports.addPad = async (authorID: string, padID: string) => {
// get the entry
const author = await db.get(`globalAuthor:${authorID}`);
// get the entry
const author = await db.get(`globalAuthor:${authorID}`);
if (author == null) return;
if (author == null) return;
/*
* ACHTUNG: padIDs can also be undefined, not just null, so it is not possible
* to perform a strict check here
*/
if (!author.padIDs) {
// the entry doesn't exist so far, let's create it
author.padIDs = {};
}
/*
* ACHTUNG: padIDs can also be undefined, not just null, so it is not possible
* to perform a strict check here
*/
if (!author.padIDs) {
// the entry doesn't exist so far, let's create it
author.padIDs = {};
}
// add the entry for this pad
author.padIDs[padID] = 1; // anything, because value is not used
// add the entry for this pad
author.padIDs[padID] = 1; // anything, because value is not used
// save the new element back
await db.set(`globalAuthor:${authorID}`, author);
// save the new element back
await db.set(`globalAuthor:${authorID}`, author);
};
/**
@ -303,13 +310,13 @@ exports.addPad = async (authorID: string, padID: string) => {
* @param {String} padID The id of the pad the author contributes to
*/
exports.removePad = async (authorID: string, padID: string) => {
const author = await db.get(`globalAuthor:${authorID}`);
const author = await db.get(`globalAuthor:${authorID}`);
if (author == null) return;
if (author == null) return;
if (author.padIDs != null) {
// remove pad from author
delete author.padIDs[padID];
await db.set(`globalAuthor:${authorID}`, author);
}
if (author.padIDs != null) {
// remove pad from author
delete author.padIDs[padID];
await db.set(`globalAuthor:${authorID}`, author);
}
};

View file

@ -1,5 +1,3 @@
'use strict';
/**
* The DB Module provides a database initialized with the settings
* provided by the settings module
@ -21,12 +19,12 @@
* limitations under the License.
*/
import ueberDB from 'ueberdb2';
const settings = require('../utils/Settings');
import log4js from 'log4js';
const stats = require('../stats')
import ueberDB from "ueberdb2";
const settings = require("../utils/Settings");
import log4js from "log4js";
const stats = require("../stats");
const logger = log4js.getLogger('ueberDB');
const logger = log4js.getLogger("ueberDB");
/**
* The UeberDB Object that provides the database functions
@ -37,24 +35,30 @@ exports.db = null;
* Initializes the database with the settings provided by the settings module
*/
exports.init = async () => {
exports.db = new ueberDB.Database(settings.dbType, settings.dbSettings, null, logger);
await exports.db.init();
if (exports.db.metrics != null) {
for (const [metric, value] of Object.entries(exports.db.metrics)) {
if (typeof value !== 'number') continue;
stats.gauge(`ueberdb_${metric}`, () => exports.db.metrics[metric]);
}
}
for (const fn of ['get', 'set', 'findKeys', 'getSub', 'setSub', 'remove']) {
const f = exports.db[fn];
exports[fn] = async (...args:string[]) => await f.call(exports.db, ...args);
Object.setPrototypeOf(exports[fn], Object.getPrototypeOf(f));
Object.defineProperties(exports[fn], Object.getOwnPropertyDescriptors(f));
}
exports.db = new ueberDB.Database(
settings.dbType,
settings.dbSettings,
null,
logger,
);
await exports.db.init();
if (exports.db.metrics != null) {
for (const [metric, value] of Object.entries(exports.db.metrics)) {
if (typeof value !== "number") continue;
stats.gauge(`ueberdb_${metric}`, () => exports.db.metrics[metric]);
}
}
for (const fn of ["get", "set", "findKeys", "getSub", "setSub", "remove"]) {
const f = exports.db[fn];
exports[fn] = async (...args: string[]) =>
await f.call(exports.db, ...args);
Object.setPrototypeOf(exports[fn], Object.getPrototypeOf(f));
Object.defineProperties(exports[fn], Object.getOwnPropertyDescriptors(f));
}
};
exports.shutdown = async (hookName: string, context:any) => {
if (exports.db != null) await exports.db.close();
exports.db = null;
logger.log('Database closed');
exports.shutdown = async (hookName: string, context: any) => {
if (exports.db != null) await exports.db.close();
exports.db = null;
logger.log("Database closed");
};

View file

@ -1,4 +1,3 @@
'use strict';
/**
* The Group Manager provides functions to manage groups in the database
*/
@ -19,22 +18,22 @@
* limitations under the License.
*/
const CustomError = require('../utils/customError');
const randomString = require('../../static/js/pad_utils').randomString;
const db = require('./DB');
const padManager = require('./PadManager');
const sessionManager = require('./SessionManager');
const CustomError = require("../utils/customError");
const randomString = require("../../static/js/pad_utils").randomString;
const db = require("./DB");
const padManager = require("./PadManager");
const sessionManager = require("./SessionManager");
/**
* Lists all groups
* @return {Promise<{groupIDs: string[]}>} The ids of all groups
*/
exports.listAllGroups = async () => {
let groups = await db.get('groups');
groups = groups || {};
let groups = await db.get("groups");
groups = groups || {};
const groupIDs = Object.keys(groups);
return {groupIDs};
const groupIDs = Object.keys(groups);
return { groupIDs };
};
/**
@ -43,38 +42,44 @@ exports.listAllGroups = async () => {
* @return {Promise<void>} Resolves when the group is deleted
*/
exports.deleteGroup = async (groupID: string): Promise<void> => {
const group = await db.get(`group:${groupID}`);
const group = await db.get(`group:${groupID}`);
// ensure group exists
if (group == null) {
// group does not exist
throw new CustomError('groupID does not exist', 'apierror');
}
// ensure group exists
if (group == null) {
// group does not exist
throw new CustomError("groupID does not exist", "apierror");
}
// iterate through all pads of this group and delete them (in parallel)
await Promise.all(Object.keys(group.pads).map(async (padId) => {
const pad = await padManager.getPad(padId);
await pad.remove();
}));
// iterate through all pads of this group and delete them (in parallel)
await Promise.all(
Object.keys(group.pads).map(async (padId) => {
const pad = await padManager.getPad(padId);
await pad.remove();
}),
);
// Delete associated sessions in parallel. This should be done before deleting the group2sessions
// record because deleting a session updates the group2sessions record.
const {sessionIDs = {}} = await db.get(`group2sessions:${groupID}`) || {};
await Promise.all(Object.keys(sessionIDs).map(async (sessionId) => {
await sessionManager.deleteSession(sessionId);
}));
// Delete associated sessions in parallel. This should be done before deleting the group2sessions
// record because deleting a session updates the group2sessions record.
const { sessionIDs = {} } = (await db.get(`group2sessions:${groupID}`)) || {};
await Promise.all(
Object.keys(sessionIDs).map(async (sessionId) => {
await sessionManager.deleteSession(sessionId);
}),
);
await Promise.all([
db.remove(`group2sessions:${groupID}`),
// UeberDB's setSub() method atomically reads the record, updates the appropriate property, and
// writes the result. Setting a property to `undefined` deletes that property (JSON.stringify()
// ignores such properties).
db.setSub('groups', [groupID], undefined),
...Object.keys(group.mappings || {}).map(async (m) => await db.remove(`mapper2group:${m}`)),
]);
await Promise.all([
db.remove(`group2sessions:${groupID}`),
// UeberDB's setSub() method atomically reads the record, updates the appropriate property, and
// writes the result. Setting a property to `undefined` deletes that property (JSON.stringify()
// ignores such properties).
db.setSub("groups", [groupID], undefined),
...Object.keys(group.mappings || {}).map(
async (m) => await db.remove(`mapper2group:${m}`),
),
]);
// Remove the group record after updating the `groups` record so that the state is consistent.
await db.remove(`group:${groupID}`);
// Remove the group record after updating the `groups` record so that the state is consistent.
await db.remove(`group:${groupID}`);
};
/**
@ -83,10 +88,10 @@ exports.deleteGroup = async (groupID: string): Promise<void> => {
* @return {Promise<boolean>} Resolves to true if the group exists
*/
exports.doesGroupExist = async (groupID: string) => {
// try to get the group entry
const group = await db.get(`group:${groupID}`);
// try to get the group entry
const group = await db.get(`group:${groupID}`);
return (group != null);
return group != null;
};
/**
@ -94,13 +99,13 @@ exports.doesGroupExist = async (groupID: string) => {
* @return {Promise<{groupID: string}>} the id of the new group
*/
exports.createGroup = async () => {
const groupID = `g.${randomString(16)}`;
await db.set(`group:${groupID}`, {pads: {}, mappings: {}});
// Add the group to the `groups` record after the group's individual record is created so that
// the state is consistent. Note: UeberDB's setSub() method atomically reads the record, updates
// the appropriate property, and writes the result.
await db.setSub('groups', [groupID], 1);
return {groupID};
const groupID = `g.${randomString(16)}`;
await db.set(`group:${groupID}`, { pads: {}, mappings: {} });
// Add the group to the `groups` record after the group's individual record is created so that
// the state is consistent. Note: UeberDB's setSub() method atomically reads the record, updates
// the appropriate property, and writes the result.
await db.setSub("groups", [groupID], 1);
return { groupID };
};
/**
@ -108,22 +113,22 @@ exports.createGroup = async () => {
* @param groupMapper the mapper of the group
* @return {Promise<{groupID: string}|{groupID: *}>} a promise that resolves to the group ID
*/
exports.createGroupIfNotExistsFor = async (groupMapper: string|object) => {
if (typeof groupMapper !== 'string') {
throw new CustomError('groupMapper is not a string', 'apierror');
}
const groupID = await db.get(`mapper2group:${groupMapper}`);
if (groupID && await exports.doesGroupExist(groupID)) return {groupID};
const result = await exports.createGroup();
await Promise.all([
db.set(`mapper2group:${groupMapper}`, result.groupID),
// Remember the mapping in the group record so that it can be cleaned up when the group is
// deleted. Although the core Etherpad API does not support multiple mappings for the same
// group, the database record does support multiple mappings in case a plugin decides to extend
// the core Etherpad functionality. (It's also easy to implement it this way.)
db.setSub(`group:${result.groupID}`, ['mappings', groupMapper], 1),
]);
return result;
exports.createGroupIfNotExistsFor = async (groupMapper: string | object) => {
if (typeof groupMapper !== "string") {
throw new CustomError("groupMapper is not a string", "apierror");
}
const groupID = await db.get(`mapper2group:${groupMapper}`);
if (groupID && (await exports.doesGroupExist(groupID))) return { groupID };
const result = await exports.createGroup();
await Promise.all([
db.set(`mapper2group:${groupMapper}`, result.groupID),
// Remember the mapping in the group record so that it can be cleaned up when the group is
// deleted. Although the core Etherpad API does not support multiple mappings for the same
// group, the database record does support multiple mappings in case a plugin decides to extend
// the core Etherpad functionality. (It's also easy to implement it this way.)
db.setSub(`group:${result.groupID}`, ["mappings", groupMapper], 1),
]);
return result;
};
/**
@ -134,32 +139,37 @@ exports.createGroupIfNotExistsFor = async (groupMapper: string|object) => {
* @param {String} authorId The id of the author
* @return {Promise<{padID: string}>} a promise that resolves to the id of the new pad
*/
exports.createGroupPad = async (groupID: string, padName: string, text: string, authorId: string = ''): Promise<{ padID: string; }> => {
// create the padID
const padID = `${groupID}$${padName}`;
exports.createGroupPad = async (
groupID: string,
padName: string,
text: string,
authorId = "",
): Promise<{ padID: string }> => {
// create the padID
const padID = `${groupID}$${padName}`;
// ensure group exists
const groupExists = await exports.doesGroupExist(groupID);
// ensure group exists
const groupExists = await exports.doesGroupExist(groupID);
if (!groupExists) {
throw new CustomError('groupID does not exist', 'apierror');
}
if (!groupExists) {
throw new CustomError("groupID does not exist", "apierror");
}
// ensure pad doesn't exist already
const padExists = await padManager.doesPadExists(padID);
// ensure pad doesn't exist already
const padExists = await padManager.doesPadExists(padID);
if (padExists) {
// pad exists already
throw new CustomError('padName does already exist', 'apierror');
}
if (padExists) {
// pad exists already
throw new CustomError("padName does already exist", "apierror");
}
// create the pad
await padManager.getPad(padID, text, authorId);
// create the pad
await padManager.getPad(padID, text, authorId);
// create an entry in the group for this pad
await db.setSub(`group:${groupID}`, ['pads', padID], 1);
// create an entry in the group for this pad
await db.setSub(`group:${groupID}`, ["pads", padID], 1);
return {padID};
return { padID };
};
/**
@ -167,17 +177,17 @@ exports.createGroupPad = async (groupID: string, padName: string, text: string,
* @param {String} groupID The id of the group
* @return {Promise<{padIDs: string[]}>} a promise that resolves to the ids of all pads of the group
*/
exports.listPads = async (groupID: string): Promise<{ padIDs: string[]; }> => {
const exists = await exports.doesGroupExist(groupID);
exports.listPads = async (groupID: string): Promise<{ padIDs: string[] }> => {
const exists = await exports.doesGroupExist(groupID);
// ensure the group exists
if (!exists) {
throw new CustomError('groupID does not exist', 'apierror');
}
// ensure the group exists
if (!exists) {
throw new CustomError("groupID does not exist", "apierror");
}
// group exists, let's get the pads
const result = await db.getSub(`group:${groupID}`, ['pads']);
const padIDs = Object.keys(result);
// group exists, let's get the pads
const result = await db.getSub(`group:${groupID}`, ["pads"]);
const padIDs = Object.keys(result);
return {padIDs};
return { padIDs };
};

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
'use strict';
/**
* The Pad Manager is a Factory for pad Objects
*/
@ -19,13 +18,13 @@
* limitations under the License.
*/
import {MapArrayType} from "../types/MapType";
import {PadType} from "../types/PadType";
import type { MapArrayType } from "../types/MapType";
import type { PadType } from "../types/PadType";
const CustomError = require('../utils/customError');
const Pad = require('../db/Pad');
const db = require('./DB');
const settings = require('../utils/Settings');
const CustomError = require("../utils/customError");
const Pad = require("../db/Pad");
const db = require("./DB");
const settings = require("../utils/Settings");
/**
* A cache of all loaded Pads.
@ -38,18 +37,16 @@ const settings = require('../utils/Settings');
* If this is needed in other places, it would be wise to make this a prototype
* that's defined somewhere more sensible.
*/
const globalPads:MapArrayType<any> = {
get(name: string)
{
return this[`:${name}`];
},
set(name: string, value: any)
{
this[`:${name}`] = value;
},
remove(name: string) {
delete this[`:${name}`];
},
const globalPads: MapArrayType<any> = {
get(name: string) {
return this[`:${name}`];
},
set(name: string, value: any) {
this[`:${name}`] = value;
},
remove(name: string) {
delete this[`:${name}`];
},
};
/**
@ -57,45 +54,45 @@ const globalPads:MapArrayType<any> = {
*
* Updated without db access as new pads are created/old ones removed.
*/
const padList = new class {
private _cachedList: string[] | null;
private _list: Set<string>;
private _loaded: Promise<void> | null;
constructor() {
this._cachedList = null;
this._list = new Set();
this._loaded = null;
}
const padList = new (class {
private _cachedList: string[] | null;
private _list: Set<string>;
private _loaded: Promise<void> | null;
constructor() {
this._cachedList = null;
this._list = new Set();
this._loaded = null;
}
/**
* Returns all pads in alphabetical order as array.
* @returns {Promise<string[]>} A promise that resolves to an array of pad IDs.
*/
async getPads() {
if (!this._loaded) {
this._loaded = (async () => {
const dbData = await db.findKeys('pad:*', '*:*:*');
if (dbData == null) return;
for (const val of dbData) this.addPad(val.replace(/^pad:/, ''));
})();
}
await this._loaded;
if (!this._cachedList) this._cachedList = [...this._list].sort();
return this._cachedList;
}
/**
* Returns all pads in alphabetical order as array.
* @returns {Promise<string[]>} A promise that resolves to an array of pad IDs.
*/
async getPads() {
if (!this._loaded) {
this._loaded = (async () => {
const dbData = await db.findKeys("pad:*", "*:*:*");
if (dbData == null) return;
for (const val of dbData) this.addPad(val.replace(/^pad:/, ""));
})();
}
await this._loaded;
if (!this._cachedList) this._cachedList = [...this._list].sort();
return this._cachedList;
}
addPad(name: string) {
if (this._list.has(name)) return;
this._list.add(name);
this._cachedList = null;
}
addPad(name: string) {
if (this._list.has(name)) return;
this._list.add(name);
this._cachedList = null;
}
removePad(name: string) {
if (!this._list.has(name)) return;
this._list.delete(name);
this._cachedList = null;
}
}();
removePad(name: string) {
if (!this._list.has(name)) return;
this._list.delete(name);
this._cachedList = null;
}
})();
// initialises the all-knowing data structure
@ -106,57 +103,58 @@ const padList = new class {
* @param {string} [authorId] - Optional author ID of the user that initiated the pad creation (if
* applicable).
*/
exports.getPad = async (id: string, text?: string|null, authorId:string|null = ''):Promise<PadType> => {
// check if this is a valid padId
if (!exports.isValidPadId(id)) {
throw new CustomError(`${id} is not a valid padId`, 'apierror');
}
exports.getPad = async (
id: string,
text?: string | null,
authorId: string | null = "",
): Promise<PadType> => {
// check if this is a valid padId
if (!exports.isValidPadId(id)) {
throw new CustomError(`${id} is not a valid padId`, "apierror");
}
// check if this is a valid text
if (text != null) {
// check if text is a string
if (typeof text !== 'string') {
throw new CustomError('text is not a string', 'apierror');
}
// check if this is a valid text
if (text != null) {
// check if text is a string
if (typeof text !== "string") {
throw new CustomError("text is not a string", "apierror");
}
// check if text is less than 100k chars
if (text.length > 100000) {
throw new CustomError('text must be less than 100k chars', 'apierror');
}
}
// check if text is less than 100k chars
if (text.length > 100000) {
throw new CustomError("text must be less than 100k chars", "apierror");
}
}
let pad = globalPads.get(id);
let pad = globalPads.get(id);
// return pad if it's already loaded
if (pad != null) {
return pad;
}
// return pad if it's already loaded
if (pad != null) {
return pad;
}
// try to load pad
pad = new Pad.Pad(id);
// try to load pad
pad = new Pad.Pad(id);
// initialize the pad
await pad.init(text, authorId);
globalPads.set(id, pad);
padList.addPad(id);
// initialize the pad
await pad.init(text, authorId);
globalPads.set(id, pad);
padList.addPad(id);
return pad;
return pad;
};
exports.listAllPads = async () => {
const padIDs = await padList.getPads();
const padIDs = await padList.getPads();
return {padIDs};
return { padIDs };
};
// checks if a pad exists
exports.doesPadExist = async (padId: string) => {
const value = await db.get(`pad:${padId}`);
const value = await db.get(`pad:${padId}`);
return (value != null && value.atext);
return value != null && value.atext;
};
// alias for backwards compatibility
@ -167,44 +165,45 @@ exports.doesPadExists = exports.doesPadExist;
* time, and allow us to "play back" these changes so legacy padIds can be found.
*/
const padIdTransforms = [
[/\s+/g, '_'],
[/:+/g, '_'],
[/\s+/g, "_"],
[/:+/g, "_"],
];
// returns a sanitized padId, respecting legacy pad id formats
exports.sanitizePadId = async (padId: string) => {
for (let i = 0, n = padIdTransforms.length; i < n; ++i) {
const exists = await exports.doesPadExist(padId);
for (let i = 0, n = padIdTransforms.length; i < n; ++i) {
const exists = await exports.doesPadExist(padId);
if (exists) {
return padId;
}
if (exists) {
return padId;
}
const [from, to] = padIdTransforms[i];
const [from, to] = padIdTransforms[i];
// @ts-ignore
padId = padId.replace(from, to);
}
// @ts-ignore
padId = padId.replace(from, to);
}
if (settings.lowerCasePadIds) padId = padId.toLowerCase();
if (settings.lowerCasePadIds) padId = padId.toLowerCase();
// we're out of possible transformations, so just return it
return padId;
// we're out of possible transformations, so just return it
return padId;
};
exports.isValidPadId = (padId: string) => /^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId);
exports.isValidPadId = (padId: string) =>
/^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId);
/**
* Removes the pad from database and unloads it.
*/
exports.removePad = async (padId: string) => {
const p = db.remove(`pad:${padId}`);
exports.unloadPad(padId);
padList.removePad(padId);
await p;
const p = db.remove(`pad:${padId}`);
exports.unloadPad(padId);
padList.removePad(padId);
await p;
};
// removes a pad from the cache
exports.unloadPad = (padId: string) => {
globalPads.remove(padId);
globalPads.remove(padId);
};

View file

@ -1,4 +1,3 @@
'use strict';
/**
* The ReadOnlyManager manages the database and rendering releated to read only pads
*/
@ -19,37 +18,35 @@
* limitations under the License.
*/
const db = require('./DB');
const randomString = require('../utils/randomstring');
const db = require("./DB");
const randomString = require("../utils/randomstring");
/**
* checks if the id pattern matches a read-only pad id
* @param {String} id the pad's id
* @return {Boolean} true if the id is readonly
*/
exports.isReadOnlyId = (id:string) => id.startsWith('r.');
exports.isReadOnlyId = (id: string) => id.startsWith("r.");
/**
* returns a read only id for a pad
* @param {String} padId the id of the pad
* @return {String} the read only id
*/
exports.getReadOnlyId = async (padId:string) => {
// check if there is a pad2readonly entry
let readOnlyId = await db.get(`pad2readonly:${padId}`);
exports.getReadOnlyId = async (padId: string) => {
// check if there is a pad2readonly entry
let readOnlyId = await db.get(`pad2readonly:${padId}`);
// there is no readOnly Entry in the database, let's create one
if (readOnlyId == null) {
readOnlyId = `r.${randomString(16)}`;
await Promise.all([
db.set(`pad2readonly:${padId}`, readOnlyId),
db.set(`readonly2pad:${readOnlyId}`, padId),
]);
}
// there is no readOnly Entry in the database, let's create one
if (readOnlyId == null) {
readOnlyId = `r.${randomString(16)}`;
await Promise.all([
db.set(`pad2readonly:${padId}`, readOnlyId),
db.set(`readonly2pad:${readOnlyId}`, padId),
]);
}
return readOnlyId;
return readOnlyId;
};
/**
@ -57,19 +54,20 @@ exports.getReadOnlyId = async (padId:string) => {
* @param {String} readOnlyId read only id
* @return {String} the padId
*/
exports.getPadId = async (readOnlyId:string) => await db.get(`readonly2pad:${readOnlyId}`);
exports.getPadId = async (readOnlyId: string) =>
await db.get(`readonly2pad:${readOnlyId}`);
/**
* returns the padId and readonlyPadId in an object for any id
* @param {String} id read only id or real pad id
* @return {Object} an object with the padId and readonlyPadId
*/
exports.getIds = async (id:string) => {
const readonly = exports.isReadOnlyId(id);
exports.getIds = async (id: string) => {
const readonly = exports.isReadOnlyId(id);
// Might be null, if this is an unknown read-only id
const readOnlyPadId = readonly ? id : await exports.getReadOnlyId(id);
const padId = readonly ? await exports.getPadId(id) : id;
// Might be null, if this is an unknown read-only id
const readOnlyPadId = readonly ? id : await exports.getReadOnlyId(id);
const padId = readonly ? await exports.getPadId(id) : id;
return {readOnlyPadId, padId, readonly};
return { readOnlyPadId, padId, readonly };
};

View file

@ -1,4 +1,3 @@
'use strict';
/**
* Controls the security of pad access
*/
@ -19,20 +18,20 @@
* limitations under the License.
*/
import {UserSettingsObject} from "../types/UserSettingsObject";
import type { UserSettingsObject } from "../types/UserSettingsObject";
const authorManager = require('./AuthorManager');
const hooks = require('../../static/js/pluginfw/hooks.js');
const padManager = require('./PadManager');
const readOnlyManager = require('./ReadOnlyManager');
const sessionManager = require('./SessionManager');
const settings = require('../utils/Settings');
const webaccess = require('../hooks/express/webaccess');
const log4js = require('log4js');
const authLogger = log4js.getLogger('auth');
const {padutils} = require('../../static/js/pad_utils');
const authorManager = require("./AuthorManager");
const hooks = require("../../static/js/pluginfw/hooks.js");
const padManager = require("./PadManager");
const readOnlyManager = require("./ReadOnlyManager");
const sessionManager = require("./SessionManager");
const settings = require("../utils/Settings");
const webaccess = require("../hooks/express/webaccess");
const log4js = require("log4js");
const authLogger = log4js.getLogger("auth");
const { padutils } = require("../../static/js/pad_utils");
const DENY = Object.freeze({accessStatus: 'deny'});
const DENY = Object.freeze({ accessStatus: "deny" });
/**
* Determines whether the user can access a pad.
@ -57,94 +56,123 @@ const DENY = Object.freeze({accessStatus: 'deny'});
* @param {Object} userSettings
* @return {DENY|{accessStatus: String, authorID: String}}
*/
exports.checkAccess = async (padID:string, sessionCookie:string, token:string, userSettings:UserSettingsObject) => {
if (!padID) {
authLogger.debug('access denied: missing padID');
return DENY;
}
exports.checkAccess = async (
padID: string,
sessionCookie: string,
token: string,
userSettings: UserSettingsObject,
) => {
if (!padID) {
authLogger.debug("access denied: missing padID");
return DENY;
}
let canCreate = !settings.editOnly;
let canCreate = !settings.editOnly;
if (readOnlyManager.isReadOnlyId(padID)) {
canCreate = false;
padID = await readOnlyManager.getPadId(padID);
if (padID == null) {
authLogger.debug('access denied: read-only pad ID for a pad that does not exist');
return DENY;
}
}
if (readOnlyManager.isReadOnlyId(padID)) {
canCreate = false;
padID = await readOnlyManager.getPadId(padID);
if (padID == null) {
authLogger.debug(
"access denied: read-only pad ID for a pad that does not exist",
);
return DENY;
}
}
// Authentication and authorization checks.
if (settings.loadTest) {
console.warn(
'bypassing socket.io authentication and authorization checks due to settings.loadTest');
} else if (settings.requireAuthentication) {
if (userSettings == null) {
authLogger.debug('access denied: authentication is required');
return DENY;
}
if (userSettings.canCreate != null && !userSettings.canCreate) canCreate = false;
if (userSettings.readOnly) canCreate = false;
// Note: userSettings.padAuthorizations should still be populated even if
// settings.requireAuthorization is false.
const padAuthzs = userSettings.padAuthorizations || {};
const level = webaccess.normalizeAuthzLevel(padAuthzs[padID]);
if (!level) {
authLogger.debug('access denied: unauthorized');
return DENY;
}
if (level !== 'create') canCreate = false;
}
// Authentication and authorization checks.
if (settings.loadTest) {
console.warn(
"bypassing socket.io authentication and authorization checks due to settings.loadTest",
);
} else if (settings.requireAuthentication) {
if (userSettings == null) {
authLogger.debug("access denied: authentication is required");
return DENY;
}
if (userSettings.canCreate != null && !userSettings.canCreate)
canCreate = false;
if (userSettings.readOnly) canCreate = false;
// Note: userSettings.padAuthorizations should still be populated even if
// settings.requireAuthorization is false.
const padAuthzs = userSettings.padAuthorizations || {};
const level = webaccess.normalizeAuthzLevel(padAuthzs[padID]);
if (!level) {
authLogger.debug("access denied: unauthorized");
return DENY;
}
if (level !== "create") canCreate = false;
}
// allow plugins to deny access
const isFalse = (x:boolean) => x === false;
if (hooks.callAll('onAccessCheck', {padID, token, sessionCookie}).some(isFalse)) {
authLogger.debug('access denied: an onAccessCheck hook function returned false');
return DENY;
}
// allow plugins to deny access
const isFalse = (x: boolean) => x === false;
if (
hooks
.callAll("onAccessCheck", { padID, token, sessionCookie })
.some(isFalse)
) {
authLogger.debug(
"access denied: an onAccessCheck hook function returned false",
);
return DENY;
}
const padExists = await padManager.doesPadExist(padID);
if (!padExists && !canCreate) {
authLogger.debug('access denied: user attempted to create a pad, which is prohibited');
return DENY;
}
const padExists = await padManager.doesPadExist(padID);
if (!padExists && !canCreate) {
authLogger.debug(
"access denied: user attempted to create a pad, which is prohibited",
);
return DENY;
}
const sessionAuthorID = await sessionManager.findAuthorID(padID.split('$')[0], sessionCookie);
if (settings.requireSession && !sessionAuthorID) {
authLogger.debug('access denied: HTTP API session is required');
return DENY;
}
if (!sessionAuthorID && token != null && !padutils.isValidAuthorToken(token)) {
// The author token should be kept secret, so do not log it.
authLogger.debug('access denied: invalid author token');
return DENY;
}
const sessionAuthorID = await sessionManager.findAuthorID(
padID.split("$")[0],
sessionCookie,
);
if (settings.requireSession && !sessionAuthorID) {
authLogger.debug("access denied: HTTP API session is required");
return DENY;
}
if (
!sessionAuthorID &&
token != null &&
!padutils.isValidAuthorToken(token)
) {
// The author token should be kept secret, so do not log it.
authLogger.debug("access denied: invalid author token");
return DENY;
}
const grant = {
accessStatus: 'grant',
authorID: sessionAuthorID || await authorManager.getAuthorId(token, userSettings),
};
const grant = {
accessStatus: "grant",
authorID:
sessionAuthorID || (await authorManager.getAuthorId(token, userSettings)),
};
if (!padID.includes('$')) {
// Only group pads can be private, so there is nothing more to check for this non-group pad.
return grant;
}
if (!padID.includes("$")) {
// Only group pads can be private, so there is nothing more to check for this non-group pad.
return grant;
}
if (!padExists) {
if (sessionAuthorID == null) {
authLogger.debug('access denied: must have an HTTP API session to create a group pad');
return DENY;
}
// Creating a group pad, so there is no public status to check.
return grant;
}
if (!padExists) {
if (sessionAuthorID == null) {
authLogger.debug(
"access denied: must have an HTTP API session to create a group pad",
);
return DENY;
}
// Creating a group pad, so there is no public status to check.
return grant;
}
const pad = await padManager.getPad(padID);
const pad = await padManager.getPad(padID);
if (!pad.getPublicStatus() && sessionAuthorID == null) {
authLogger.debug('access denied: must have an HTTP API session to access private group pads');
return DENY;
}
if (!pad.getPublicStatus() && sessionAuthorID == null) {
authLogger.debug(
"access denied: must have an HTTP API session to access private group pads",
);
return DENY;
}
return grant;
return grant;
};

View file

@ -1,4 +1,3 @@
'use strict';
/**
* The Session Manager provides functions to manage session in the database,
* it only provides session management for sessions created by the API
@ -20,12 +19,12 @@
* limitations under the License.
*/
const CustomError = require('../utils/customError');
const promises = require('../utils/promises');
const randomString = require('../utils/randomstring');
const db = require('./DB');
const groupManager = require('./GroupManager');
const authorManager = require('./AuthorManager');
const CustomError = require("../utils/customError");
const promises = require("../utils/promises");
const randomString = require("../utils/randomstring");
const db = require("./DB");
const groupManager = require("./GroupManager");
const authorManager = require("./AuthorManager");
/**
* Finds the author ID for a session with matching ID and group.
@ -36,52 +35,59 @@ const authorManager = require('./AuthorManager');
* sessionCookie, and is bound to a group with the given ID, then this returns the author ID
* bound to the session. Otherwise, returns undefined.
*/
exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
if (!sessionCookie) return undefined;
/*
* Sometimes, RFC 6265-compliant web servers may send back a cookie whose
* value is enclosed in double quotes, such as:
*
* Set-Cookie: sessionCookie="s.37cf5299fbf981e14121fba3a588c02b,
* s.2b21517bf50729d8130ab85736a11346"; Version=1; Path=/; Domain=localhost; Discard
*
* Where the double quotes at the start and the end of the header value are
* just delimiters. This is perfectly legal: Etherpad parsing logic should
* cope with that, and remove the quotes early in the request phase.
*
* Somehow, this does not happen, and in such cases the actual value that
* sessionCookie ends up having is:
*
* sessionCookie = '"s.37cf5299fbf981e14121fba3a588c02b,s.2b21517bf50729d8130ab85736a11346"'
*
* As quick measure, let's strip the double quotes (when present).
* Note that here we are being minimal, limiting ourselves to just removing
* quotes at the start and the end of the string.
*
* Fixes #3819.
* Also, see #3820.
*/
const sessionIDs = sessionCookie.replace(/^"|"$/g, '').split(',');
const sessionInfoPromises = sessionIDs.map(async (id) => {
try {
return await exports.getSessionInfo(id);
} catch (err:any) {
if (err.message === 'sessionID does not exist') {
console.debug(`SessionManager getAuthorID: no session exists with ID ${id}`);
} else {
throw err;
}
}
return undefined;
});
const now = Math.floor(Date.now() / 1000);
const isMatch = (si: {
groupID: string;
validUntil: number;
}|null) => (si != null && si.groupID === groupID && now < si.validUntil);
const sessionInfo = await promises.firstSatisfies(sessionInfoPromises, isMatch);
if (sessionInfo == null) return undefined;
return sessionInfo.authorID;
exports.findAuthorID = async (groupID: string, sessionCookie: string) => {
if (!sessionCookie) return undefined;
/*
* Sometimes, RFC 6265-compliant web servers may send back a cookie whose
* value is enclosed in double quotes, such as:
*
* Set-Cookie: sessionCookie="s.37cf5299fbf981e14121fba3a588c02b,
* s.2b21517bf50729d8130ab85736a11346"; Version=1; Path=/; Domain=localhost; Discard
*
* Where the double quotes at the start and the end of the header value are
* just delimiters. This is perfectly legal: Etherpad parsing logic should
* cope with that, and remove the quotes early in the request phase.
*
* Somehow, this does not happen, and in such cases the actual value that
* sessionCookie ends up having is:
*
* sessionCookie = '"s.37cf5299fbf981e14121fba3a588c02b,s.2b21517bf50729d8130ab85736a11346"'
*
* As quick measure, let's strip the double quotes (when present).
* Note that here we are being minimal, limiting ourselves to just removing
* quotes at the start and the end of the string.
*
* Fixes #3819.
* Also, see #3820.
*/
const sessionIDs = sessionCookie.replace(/^"|"$/g, "").split(",");
const sessionInfoPromises = sessionIDs.map(async (id) => {
try {
return await exports.getSessionInfo(id);
} catch (err: any) {
if (err.message === "sessionID does not exist") {
console.debug(
`SessionManager getAuthorID: no session exists with ID ${id}`,
);
} else {
throw err;
}
}
return undefined;
});
const now = Math.floor(Date.now() / 1000);
const isMatch = (
si: {
groupID: string;
validUntil: number;
} | null,
) => si != null && si.groupID === groupID && now < si.validUntil;
const sessionInfo = await promises.firstSatisfies(
sessionInfoPromises,
isMatch,
);
if (sessionInfo == null) return undefined;
return sessionInfo.authorID;
};
/**
@ -90,9 +96,9 @@ exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
* @return {Promise<boolean>} Resolves to true if the session exists
*/
exports.doesSessionExist = async (sessionID: string) => {
// check if the database entry of this session exists
const session = await db.get(`session:${sessionID}`);
return (session != null);
// check if the database entry of this session exists
const session = await db.get(`session:${sessionID}`);
return session != null;
};
/**
@ -102,60 +108,64 @@ exports.doesSessionExist = async (sessionID: string) => {
* @param {Number} validUntil The unix timestamp when the session should expire
* @return {Promise<{sessionID: string}>} the id of the new session
*/
exports.createSession = async (groupID: string, authorID: string, validUntil: number) => {
// check if the group exists
const groupExists = await groupManager.doesGroupExist(groupID);
if (!groupExists) {
throw new CustomError('groupID does not exist', 'apierror');
}
exports.createSession = async (
groupID: string,
authorID: string,
validUntil: number,
) => {
// check if the group exists
const groupExists = await groupManager.doesGroupExist(groupID);
if (!groupExists) {
throw new CustomError("groupID does not exist", "apierror");
}
// check if the author exists
const authorExists = await authorManager.doesAuthorExist(authorID);
if (!authorExists) {
throw new CustomError('authorID does not exist', 'apierror');
}
// check if the author exists
const authorExists = await authorManager.doesAuthorExist(authorID);
if (!authorExists) {
throw new CustomError("authorID does not exist", "apierror");
}
// try to parse validUntil if it's not a number
if (typeof validUntil !== 'number') {
validUntil = parseInt(validUntil);
}
// try to parse validUntil if it's not a number
if (typeof validUntil !== "number") {
validUntil = Number.parseInt(validUntil);
}
// check it's a valid number
if (isNaN(validUntil)) {
throw new CustomError('validUntil is not a number', 'apierror');
}
// check it's a valid number
if (isNaN(validUntil)) {
throw new CustomError("validUntil is not a number", "apierror");
}
// ensure this is not a negative number
if (validUntil < 0) {
throw new CustomError('validUntil is a negative number', 'apierror');
}
// ensure this is not a negative number
if (validUntil < 0) {
throw new CustomError("validUntil is a negative number", "apierror");
}
// ensure this is not a float value
if (!isInt(validUntil)) {
throw new CustomError('validUntil is a float value', 'apierror');
}
// ensure this is not a float value
if (!isInt(validUntil)) {
throw new CustomError("validUntil is a float value", "apierror");
}
// check if validUntil is in the future
if (validUntil < Math.floor(Date.now() / 1000)) {
throw new CustomError('validUntil is in the past', 'apierror');
}
// check if validUntil is in the future
if (validUntil < Math.floor(Date.now() / 1000)) {
throw new CustomError("validUntil is in the past", "apierror");
}
// generate sessionID
const sessionID = `s.${randomString(16)}`;
// generate sessionID
const sessionID = `s.${randomString(16)}`;
// set the session into the database
await db.set(`session:${sessionID}`, {groupID, authorID, validUntil});
// set the session into the database
await db.set(`session:${sessionID}`, { groupID, authorID, validUntil });
// Add the session ID to the group2sessions and author2sessions records after creating the session
// so that the state is consistent.
await Promise.all([
// UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object
// property, and writes the result.
db.setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], 1),
db.setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], 1),
]);
// Add the session ID to the group2sessions and author2sessions records after creating the session
// so that the state is consistent.
await Promise.all([
// UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object
// property, and writes the result.
db.setSub(`group2sessions:${groupID}`, ["sessionIDs", sessionID], 1),
db.setSub(`author2sessions:${authorID}`, ["sessionIDs", sessionID], 1),
]);
return {sessionID};
return { sessionID };
};
/**
@ -163,17 +173,17 @@ exports.createSession = async (groupID: string, authorID: string, validUntil: nu
* @param {String} sessionID The id of the session
* @return {Promise<Object>} the sessioninfos
*/
exports.getSessionInfo = async (sessionID:string) => {
// check if the database entry of this session exists
const session = await db.get(`session:${sessionID}`);
exports.getSessionInfo = async (sessionID: string) => {
// check if the database entry of this session exists
const session = await db.get(`session:${sessionID}`);
if (session == null) {
// session does not exist
throw new CustomError('sessionID does not exist', 'apierror');
}
if (session == null) {
// session does not exist
throw new CustomError("sessionID does not exist", "apierror");
}
// everything is fine, return the sessioninfos
return session;
// everything is fine, return the sessioninfos
return session;
};
/**
@ -181,28 +191,36 @@ exports.getSessionInfo = async (sessionID:string) => {
* @param {String} sessionID The id of the session
* @return {Promise<void>} Resolves when the session is deleted
*/
exports.deleteSession = async (sessionID:string) => {
// ensure that the session exists
const session = await db.get(`session:${sessionID}`);
if (session == null) {
throw new CustomError('sessionID does not exist', 'apierror');
}
exports.deleteSession = async (sessionID: string) => {
// ensure that the session exists
const session = await db.get(`session:${sessionID}`);
if (session == null) {
throw new CustomError("sessionID does not exist", "apierror");
}
// everything is fine, use the sessioninfos
const groupID = session.groupID;
const authorID = session.authorID;
// everything is fine, use the sessioninfos
const groupID = session.groupID;
const authorID = session.authorID;
await Promise.all([
// UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object
// property, and writes the result. Setting a property to `undefined` deletes that property
// (JSON.stringify() ignores such properties).
db.setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], undefined),
db.setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], undefined),
]);
await Promise.all([
// UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object
// property, and writes the result. Setting a property to `undefined` deletes that property
// (JSON.stringify() ignores such properties).
db.setSub(
`group2sessions:${groupID}`,
["sessionIDs", sessionID],
undefined,
),
db.setSub(
`author2sessions:${authorID}`,
["sessionIDs", sessionID],
undefined,
),
]);
// Delete the session record after updating group2sessions and author2sessions so that the state
// is consistent.
await db.remove(`session:${sessionID}`);
// Delete the session record after updating group2sessions and author2sessions so that the state
// is consistent.
await db.remove(`session:${sessionID}`);
};
/**
@ -211,14 +229,14 @@ exports.deleteSession = async (sessionID:string) => {
* @return {Promise<Object>} The sessioninfos of all sessions of this group
*/
exports.listSessionsOfGroup = async (groupID: string) => {
// check that the group exists
const exists = await groupManager.doesGroupExist(groupID);
if (!exists) {
throw new CustomError('groupID does not exist', 'apierror');
}
// check that the group exists
const exists = await groupManager.doesGroupExist(groupID);
if (!exists) {
throw new CustomError("groupID does not exist", "apierror");
}
const sessions = await listSessionsWithDBKey(`group2sessions:${groupID}`);
return sessions;
const sessions = await listSessionsWithDBKey(`group2sessions:${groupID}`);
return sessions;
};
/**
@ -227,13 +245,13 @@ exports.listSessionsOfGroup = async (groupID: string) => {
* @return {Promise<Object>} The sessioninfos of all sessions of this author
*/
exports.listSessionsOfAuthor = async (authorID: string) => {
// check that the author exists
const exists = await authorManager.doesAuthorExist(authorID);
if (!exists) {
throw new CustomError('authorID does not exist', 'apierror');
}
// check that the author exists
const exists = await authorManager.doesAuthorExist(authorID);
if (!exists) {
throw new CustomError("authorID does not exist", "apierror");
}
return await listSessionsWithDBKey(`author2sessions:${authorID}`);
return await listSessionsWithDBKey(`author2sessions:${authorID}`);
};
// this function is basically the code listSessionsOfAuthor and listSessionsOfGroup has in common
@ -244,32 +262,32 @@ exports.listSessionsOfAuthor = async (authorID: string) => {
* @return {Promise<*>}
*/
const listSessionsWithDBKey = async (dbkey: string) => {
// get the group2sessions entry
const sessionObject = await db.get(dbkey);
const sessions = sessionObject ? sessionObject.sessionIDs : null;
// get the group2sessions entry
const sessionObject = await db.get(dbkey);
const sessions = sessionObject ? sessionObject.sessionIDs : null;
// iterate through the sessions and get the sessioninfos
for (const sessionID of Object.keys(sessions || {})) {
try {
sessions[sessionID] = await exports.getSessionInfo(sessionID);
} catch (err:any) {
if (err.name === 'apierror') {
console.warn(`Found bad session ${sessionID} in ${dbkey}`);
sessions[sessionID] = null;
} else {
throw err;
}
}
}
// iterate through the sessions and get the sessioninfos
for (const sessionID of Object.keys(sessions || {})) {
try {
sessions[sessionID] = await exports.getSessionInfo(sessionID);
} catch (err: any) {
if (err.name === "apierror") {
console.warn(`Found bad session ${sessionID} in ${dbkey}`);
sessions[sessionID] = null;
} else {
throw err;
}
}
}
return sessions;
return sessions;
};
/**
* checks if a number is an int
* @param {number|string} value
* @return {boolean} If the value is an integer
*/
// @ts-ignore
const isInt = (value:number|string): boolean => (parseFloat(value) === parseInt(value)) && !isNaN(value);
const isInt = (value: number | string): boolean =>
Number.parseFloat(value) === Number.parseInt(value) && !isNaN(value);

View file

@ -1,114 +1,119 @@
'use strict';
const DB = require("./DB");
const Store = require("express-session").Store;
const log4js = require("log4js");
const util = require("util");
const DB = require('./DB');
const Store = require('express-session').Store;
const log4js = require('log4js');
const util = require('util');
const logger = log4js.getLogger('SessionStore');
const logger = log4js.getLogger("SessionStore");
class SessionStore extends Store {
/**
* @param {?number} [refresh] - How often (in milliseconds) `touch()` will update a session's
* database record with the cookie's latest expiration time. If the difference between the
* value saved in the database and the actual value is greater than this amount, the database
* record will be updated to reflect the actual value. Use this to avoid continual database
* writes caused by express-session's rolling=true feature (see
* https://github.com/expressjs/session#rolling). A good value is high enough to keep query
* rate low but low enough to avoid annoying premature logouts (session invalidation) if
* Etherpad is restarted. Use `null` to prevent `touch()` from ever updating the record.
* Ignored if the cookie does not expire.
*/
constructor(refresh = null) {
super();
this._refresh = refresh;
// Maps session ID to an object with the following properties:
// - `db`: Session expiration as recorded in the database (ms since epoch, not a Date).
// - `real`: Actual session expiration (ms since epoch, not a Date). Always greater than or
// equal to `db`.
// - `timeout`: Timeout ID for a timeout that will clean up the database record.
this._expirations = new Map();
}
/**
* @param {?number} [refresh] - How often (in milliseconds) `touch()` will update a session's
* database record with the cookie's latest expiration time. If the difference between the
* value saved in the database and the actual value is greater than this amount, the database
* record will be updated to reflect the actual value. Use this to avoid continual database
* writes caused by express-session's rolling=true feature (see
* https://github.com/expressjs/session#rolling). A good value is high enough to keep query
* rate low but low enough to avoid annoying premature logouts (session invalidation) if
* Etherpad is restarted. Use `null` to prevent `touch()` from ever updating the record.
* Ignored if the cookie does not expire.
*/
constructor(refresh = null) {
super();
this._refresh = refresh;
// Maps session ID to an object with the following properties:
// - `db`: Session expiration as recorded in the database (ms since epoch, not a Date).
// - `real`: Actual session expiration (ms since epoch, not a Date). Always greater than or
// equal to `db`.
// - `timeout`: Timeout ID for a timeout that will clean up the database record.
this._expirations = new Map();
}
shutdown() {
for (const {timeout} of this._expirations.values()) clearTimeout(timeout);
}
shutdown() {
for (const { timeout } of this._expirations.values()) clearTimeout(timeout);
}
async _updateExpirations(sid: string, sess: any, updateDbExp = true) {
const exp = this._expirations.get(sid) || {};
clearTimeout(exp.timeout);
// @ts-ignore
const {cookie: {expires} = {}} = sess || {};
if (expires) {
const sessExp = new Date(expires).getTime();
if (updateDbExp) exp.db = sessExp;
exp.real = Math.max(exp.real || 0, exp.db || 0, sessExp);
const now = Date.now();
if (exp.real <= now) return await this._destroy(sid);
// If reading from the database, update the expiration with the latest value from touch() so
// that touch() appears to write to the database every time even though it doesn't.
if (typeof expires === 'string') sess.cookie.expires = new Date(exp.real).toJSON();
// Use this._get(), not this._destroy(), to destroy the DB record for the expired session.
// This is done in case multiple Etherpad instances are sharing the same database and users
// are bouncing between the instances. By using this._get(), this instance will query the DB
// for the latest expiration time written by any of the instances, ensuring that the record
// isn't prematurely deleted if the expiration time was updated by a different Etherpad
// instance. (Important caveat: Client-side database caching, which ueberdb does by default,
// could still cause the record to be prematurely deleted because this instance might get a
// stale expiration time from cache.)
exp.timeout = setTimeout(() => this._get(sid), exp.real - now);
this._expirations.set(sid, exp);
} else {
this._expirations.delete(sid);
}
return sess;
}
async _updateExpirations(sid: string, sess: any, updateDbExp = true) {
const exp = this._expirations.get(sid) || {};
clearTimeout(exp.timeout);
// @ts-ignore
const {
cookie: { expires } = {},
} = sess || {};
if (expires) {
const sessExp = new Date(expires).getTime();
if (updateDbExp) exp.db = sessExp;
exp.real = Math.max(exp.real || 0, exp.db || 0, sessExp);
const now = Date.now();
if (exp.real <= now) return await this._destroy(sid);
// If reading from the database, update the expiration with the latest value from touch() so
// that touch() appears to write to the database every time even though it doesn't.
if (typeof expires === "string")
sess.cookie.expires = new Date(exp.real).toJSON();
// Use this._get(), not this._destroy(), to destroy the DB record for the expired session.
// This is done in case multiple Etherpad instances are sharing the same database and users
// are bouncing between the instances. By using this._get(), this instance will query the DB
// for the latest expiration time written by any of the instances, ensuring that the record
// isn't prematurely deleted if the expiration time was updated by a different Etherpad
// instance. (Important caveat: Client-side database caching, which ueberdb does by default,
// could still cause the record to be prematurely deleted because this instance might get a
// stale expiration time from cache.)
exp.timeout = setTimeout(() => this._get(sid), exp.real - now);
this._expirations.set(sid, exp);
} else {
this._expirations.delete(sid);
}
return sess;
}
async _write(sid: string, sess: any) {
await DB.set(`sessionstorage:${sid}`, sess);
}
async _write(sid: string, sess: any) {
await DB.set(`sessionstorage:${sid}`, sess);
}
async _get(sid: string) {
logger.debug(`GET ${sid}`);
const s = await DB.get(`sessionstorage:${sid}`);
return await this._updateExpirations(sid, s);
}
async _get(sid: string) {
logger.debug(`GET ${sid}`);
const s = await DB.get(`sessionstorage:${sid}`);
return await this._updateExpirations(sid, s);
}
async _set(sid: string, sess:any) {
logger.debug(`SET ${sid}`);
sess = await this._updateExpirations(sid, sess);
if (sess != null) await this._write(sid, sess);
}
async _set(sid: string, sess: any) {
logger.debug(`SET ${sid}`);
sess = await this._updateExpirations(sid, sess);
if (sess != null) await this._write(sid, sess);
}
async _destroy(sid:string) {
logger.debug(`DESTROY ${sid}`);
clearTimeout((this._expirations.get(sid) || {}).timeout);
this._expirations.delete(sid);
await DB.remove(`sessionstorage:${sid}`);
}
async _destroy(sid: string) {
logger.debug(`DESTROY ${sid}`);
clearTimeout((this._expirations.get(sid) || {}).timeout);
this._expirations.delete(sid);
await DB.remove(`sessionstorage:${sid}`);
}
// Note: express-session might call touch() before it calls set() for the first time. Ideally this
// would behave like set() in that case but it's OK if it doesn't -- express-session will call
// set() soon enough.
async _touch(sid: string, sess:any) {
logger.debug(`TOUCH ${sid}`);
sess = await this._updateExpirations(sid, sess, false);
if (sess == null) return; // Already expired.
const exp = this._expirations.get(sid);
// If the session doesn't expire, don't do anything. Ideally we would write the session to the
// database if it didn't already exist, but we have no way of knowing that without querying the
// database. The query overhead is not worth it because set() should be called soon anyway.
if (exp == null) return;
if (exp.db != null && (this._refresh == null || exp.real < exp.db + this._refresh)) return;
await this._write(sid, sess);
exp.db = new Date(sess.cookie.expires).getTime();
}
// Note: express-session might call touch() before it calls set() for the first time. Ideally this
// would behave like set() in that case but it's OK if it doesn't -- express-session will call
// set() soon enough.
async _touch(sid: string, sess: any) {
logger.debug(`TOUCH ${sid}`);
sess = await this._updateExpirations(sid, sess, false);
if (sess == null) return; // Already expired.
const exp = this._expirations.get(sid);
// If the session doesn't expire, don't do anything. Ideally we would write the session to the
// database if it didn't already exist, but we have no way of knowing that without querying the
// database. The query overhead is not worth it because set() should be called soon anyway.
if (exp == null) return;
if (
exp.db != null &&
(this._refresh == null || exp.real < exp.db + this._refresh)
)
return;
await this._write(sid, sess);
exp.db = new Date(sess.cookie.expires).getTime();
}
}
// express-session doesn't support Promise-based methods. This is where the callbackified versions
// used by express-session are defined.
for (const m of ['get', 'set', 'destroy', 'touch']) {
SessionStore.prototype[m] = util.callbackify(SessionStore.prototype[`_${m}`]);
for (const m of ["get", "set", "destroy", "touch"]) {
SessionStore.prototype[m] = util.callbackify(SessionStore.prototype[`_${m}`]);
}
module.exports = SessionStore;

View file

@ -1,4 +1,3 @@
'use strict';
/*
* Copyright (c) 2011 RedHog (Egil Möller) <egil.moller@freecode.no>
*
@ -20,94 +19,106 @@
* require("./index").require("./path/to/template.ejs")
*/
const ejs = require('ejs');
const fs = require('fs');
const hooks = require('../../static/js/pluginfw/hooks.js');
const path = require('path');
const resolve = require('resolve');
const settings = require('../utils/Settings');
import {pluginInstallPath} from '../../static/js/pluginfw/installer'
const ejs = require("ejs");
const fs = require("fs");
const hooks = require("../../static/js/pluginfw/hooks.js");
const path = require("path");
const resolve = require("resolve");
const settings = require("../utils/Settings");
import { pluginInstallPath } from "../../static/js/pluginfw/installer";
const templateCache = new Map();
exports.info = {
__output_stack: [],
block_stack: [],
file_stack: [],
args: [],
__output_stack: [],
block_stack: [],
file_stack: [],
args: [],
};
const getCurrentFile = () => exports.info.file_stack[exports.info.file_stack.length - 1];
const getCurrentFile = () =>
exports.info.file_stack[exports.info.file_stack.length - 1];
exports._init = (b: any, recursive: boolean) => {
exports.info.__output_stack.push(exports.info.__output);
exports.info.__output = b;
exports.info.__output_stack.push(exports.info.__output);
exports.info.__output = b;
};
exports._exit = (b:any, recursive:boolean) => {
exports.info.__output = exports.info.__output_stack.pop();
exports._exit = (b: any, recursive: boolean) => {
exports.info.__output = exports.info.__output_stack.pop();
};
exports.begin_block = (name:string) => {
exports.info.block_stack.push(name);
exports.info.__output_stack.push(exports.info.__output.get());
exports.info.__output.set('');
exports.begin_block = (name: string) => {
exports.info.block_stack.push(name);
exports.info.__output_stack.push(exports.info.__output.get());
exports.info.__output.set("");
};
exports.end_block = () => {
const name = exports.info.block_stack.pop();
const renderContext = exports.info.args[exports.info.args.length - 1];
const content = exports.info.__output.get();
exports.info.__output.set(exports.info.__output_stack.pop());
const args = {content, renderContext};
hooks.callAll(`eejsBlock_${name}`, args);
exports.info.__output.set(exports.info.__output.get().concat(args.content));
const name = exports.info.block_stack.pop();
const renderContext = exports.info.args[exports.info.args.length - 1];
const content = exports.info.__output.get();
exports.info.__output.set(exports.info.__output_stack.pop());
const args = { content, renderContext };
hooks.callAll(`eejsBlock_${name}`, args);
exports.info.__output.set(exports.info.__output.get().concat(args.content));
};
exports.require = (name:string, args:{
e?: Function,
require?: Function,
}, mod:{
filename:string,
paths:string[],
}) => {
if (args == null) args = {};
exports.require = (
name: string,
args: {
e?: Function;
require?: Function;
},
mod: {
filename: string;
paths: string[];
},
) => {
if (args == null) args = {};
let basedir = __dirname;
let paths:string[] = [];
let basedir = __dirname;
let paths: string[] = [];
if (exports.info.file_stack.length) {
basedir = path.dirname(getCurrentFile().path);
}
if (mod) {
basedir = path.dirname(mod.filename);
paths = mod.paths;
}
if (exports.info.file_stack.length) {
basedir = path.dirname(getCurrentFile().path);
}
if (mod) {
basedir = path.dirname(mod.filename);
paths = mod.paths;
}
/**
* Add the plugin install path to the paths array
*/
if (!paths.includes(pluginInstallPath)) {
paths.push(pluginInstallPath)
}
/**
* Add the plugin install path to the paths array
*/
if (!paths.includes(pluginInstallPath)) {
paths.push(pluginInstallPath);
}
const ejspath = resolve.sync(name, {paths, basedir, extensions: ['.html', '.ejs']});
const ejspath = resolve.sync(name, {
paths,
basedir,
extensions: [".html", ".ejs"],
});
args.e = exports;
args.require = require;
args.e = exports;
args.require = require;
const cache = settings.maxAge !== 0;
const template = cache && templateCache.get(ejspath) || ejs.compile(
'<% e._init({get: () => __output, set: (s) => { __output = s; }}); %>' +
`${fs.readFileSync(ejspath).toString()}<% e._exit(); %>`,
{filename: ejspath});
if (cache) templateCache.set(ejspath, template);
const cache = settings.maxAge !== 0;
const template =
(cache && templateCache.get(ejspath)) ||
ejs.compile(
"<% e._init({get: () => __output, set: (s) => { __output = s; }}); %>" +
`${fs.readFileSync(ejspath).toString()}<% e._exit(); %>`,
{ filename: ejspath },
);
if (cache) templateCache.set(ejspath, template);
exports.info.args.push(args);
exports.info.file_stack.push({path: ejspath});
const res = template(args);
exports.info.file_stack.pop();
exports.info.args.pop();
exports.info.args.push(args);
exports.info.file_stack.push({ path: ejspath });
const res = template(args);
exports.info.file_stack.pop();
exports.info.args.pop();
return res;
return res;
};

View file

@ -1,4 +1,3 @@
'use strict';
/**
* The API Handler handles all API http requests
*/
@ -19,140 +18,139 @@
* limitations under the License.
*/
import {MapArrayType} from "../types/MapType";
import type { MapArrayType } from "../types/MapType";
const api = require('../db/API');
const padManager = require('../db/PadManager');
import createHTTPError from 'http-errors';
import {Http2ServerRequest, Http2ServerResponse} from "node:http2";
import {publicKeyExported} from "../security/OAuth2Provider";
import {jwtVerify} from "jose";
const api = require("../db/API");
const padManager = require("../db/PadManager");
import type { Http2ServerRequest, Http2ServerResponse } from "node:http2";
import createHTTPError from "http-errors";
import { jwtVerify } from "jose";
import { publicKeyExported } from "../security/OAuth2Provider";
// a list of all functions
const version:MapArrayType<any> = {};
const version: MapArrayType<any> = {};
version['1'] = {
createGroup: [],
createGroupIfNotExistsFor: ['groupMapper'],
deleteGroup: ['groupID'],
listPads: ['groupID'],
createPad: ['padID', 'text'],
createGroupPad: ['groupID', 'padName', 'text'],
createAuthor: ['name'],
createAuthorIfNotExistsFor: ['authorMapper', 'name'],
listPadsOfAuthor: ['authorID'],
createSession: ['groupID', 'authorID', 'validUntil'],
deleteSession: ['sessionID'],
getSessionInfo: ['sessionID'],
listSessionsOfGroup: ['groupID'],
listSessionsOfAuthor: ['authorID'],
getText: ['padID', 'rev'],
setText: ['padID', 'text'],
getHTML: ['padID', 'rev'],
setHTML: ['padID', 'html'],
getRevisionsCount: ['padID'],
getLastEdited: ['padID'],
deletePad: ['padID'],
getReadOnlyID: ['padID'],
setPublicStatus: ['padID', 'publicStatus'],
getPublicStatus: ['padID'],
listAuthorsOfPad: ['padID'],
padUsersCount: ['padID'],
version["1"] = {
createGroup: [],
createGroupIfNotExistsFor: ["groupMapper"],
deleteGroup: ["groupID"],
listPads: ["groupID"],
createPad: ["padID", "text"],
createGroupPad: ["groupID", "padName", "text"],
createAuthor: ["name"],
createAuthorIfNotExistsFor: ["authorMapper", "name"],
listPadsOfAuthor: ["authorID"],
createSession: ["groupID", "authorID", "validUntil"],
deleteSession: ["sessionID"],
getSessionInfo: ["sessionID"],
listSessionsOfGroup: ["groupID"],
listSessionsOfAuthor: ["authorID"],
getText: ["padID", "rev"],
setText: ["padID", "text"],
getHTML: ["padID", "rev"],
setHTML: ["padID", "html"],
getRevisionsCount: ["padID"],
getLastEdited: ["padID"],
deletePad: ["padID"],
getReadOnlyID: ["padID"],
setPublicStatus: ["padID", "publicStatus"],
getPublicStatus: ["padID"],
listAuthorsOfPad: ["padID"],
padUsersCount: ["padID"],
};
version['1.1'] = {
...version['1'],
getAuthorName: ['authorID'],
padUsers: ['padID'],
sendClientsMessage: ['padID', 'msg'],
listAllGroups: [],
version["1.1"] = {
...version["1"],
getAuthorName: ["authorID"],
padUsers: ["padID"],
sendClientsMessage: ["padID", "msg"],
listAllGroups: [],
};
version['1.2'] = {
...version['1.1'],
checkToken: [],
version["1.2"] = {
...version["1.1"],
checkToken: [],
};
version['1.2.1'] = {
...version['1.2'],
listAllPads: [],
version["1.2.1"] = {
...version["1.2"],
listAllPads: [],
};
version['1.2.7'] = {
...version['1.2.1'],
createDiffHTML: ['padID', 'startRev', 'endRev'],
getChatHistory: ['padID', 'start', 'end'],
getChatHead: ['padID'],
version["1.2.7"] = {
...version["1.2.1"],
createDiffHTML: ["padID", "startRev", "endRev"],
getChatHistory: ["padID", "start", "end"],
getChatHead: ["padID"],
};
version['1.2.8'] = {
...version['1.2.7'],
getAttributePool: ['padID'],
getRevisionChangeset: ['padID', 'rev'],
version["1.2.8"] = {
...version["1.2.7"],
getAttributePool: ["padID"],
getRevisionChangeset: ["padID", "rev"],
};
version['1.2.9'] = {
...version['1.2.8'],
copyPad: ['sourceID', 'destinationID', 'force'],
movePad: ['sourceID', 'destinationID', 'force'],
version["1.2.9"] = {
...version["1.2.8"],
copyPad: ["sourceID", "destinationID", "force"],
movePad: ["sourceID", "destinationID", "force"],
};
version['1.2.10'] = {
...version['1.2.9'],
getPadID: ['roID'],
version["1.2.10"] = {
...version["1.2.9"],
getPadID: ["roID"],
};
version['1.2.11'] = {
...version['1.2.10'],
getSavedRevisionsCount: ['padID'],
listSavedRevisions: ['padID'],
saveRevision: ['padID', 'rev'],
restoreRevision: ['padID', 'rev'],
version["1.2.11"] = {
...version["1.2.10"],
getSavedRevisionsCount: ["padID"],
listSavedRevisions: ["padID"],
saveRevision: ["padID", "rev"],
restoreRevision: ["padID", "rev"],
};
version['1.2.12'] = {
...version['1.2.11'],
appendChatMessage: ['padID', 'text', 'authorID', 'time'],
version["1.2.12"] = {
...version["1.2.11"],
appendChatMessage: ["padID", "text", "authorID", "time"],
};
version['1.2.13'] = {
...version['1.2.12'],
appendText: ['padID', 'text'],
version["1.2.13"] = {
...version["1.2.12"],
appendText: ["padID", "text"],
};
version['1.2.14'] = {
...version['1.2.13'],
getStats: [],
version["1.2.14"] = {
...version["1.2.13"],
getStats: [],
};
version['1.2.15'] = {
...version['1.2.14'],
copyPadWithoutHistory: ['sourceID', 'destinationID', 'force'],
version["1.2.15"] = {
...version["1.2.14"],
copyPadWithoutHistory: ["sourceID", "destinationID", "force"],
};
version['1.3.0'] = {
...version['1.2.15'],
appendText: ['padID', 'text', 'authorId'],
copyPadWithoutHistory: ['sourceID', 'destinationID', 'force', 'authorId'],
createGroupPad: ['groupID', 'padName', 'text', 'authorId'],
createPad: ['padID', 'text', 'authorId'],
restoreRevision: ['padID', 'rev', 'authorId'],
setHTML: ['padID', 'html', 'authorId'],
setText: ['padID', 'text', 'authorId'],
version["1.3.0"] = {
...version["1.2.15"],
appendText: ["padID", "text", "authorId"],
copyPadWithoutHistory: ["sourceID", "destinationID", "force", "authorId"],
createGroupPad: ["groupID", "padName", "text", "authorId"],
createPad: ["padID", "text", "authorId"],
restoreRevision: ["padID", "rev", "authorId"],
setHTML: ["padID", "html", "authorId"],
setText: ["padID", "text", "authorId"],
};
// set the latest available API version here
exports.latestApiVersion = '1.3.0';
exports.latestApiVersion = "1.3.0";
// exports the versions so it can be used by the new Swagger endpoint
exports.version = version;
type APIFields = {
api_key: string;
padID: string;
padName: string;
}
api_key: string;
padID: string;
padName: string;
};
/**
* Handles an HTTP API call
@ -162,46 +160,54 @@ type APIFields = {
* @param req express request object
* @param res express response object
*/
exports.handle = async function (apiVersion: string, functionName: string, fields: APIFields, req: Http2ServerRequest, res: Http2ServerResponse) {
// say goodbye if this is an unknown API version
if (!(apiVersion in version)) {
throw new createHTTPError.NotFound('no such api version');
}
exports.handle = async function (
apiVersion: string,
functionName: string,
fields: APIFields,
req: Http2ServerRequest,
res: Http2ServerResponse,
) {
// say goodbye if this is an unknown API version
if (!(apiVersion in version)) {
throw new createHTTPError.NotFound("no such api version");
}
// say goodbye if this is an unknown function
if (!(functionName in version[apiVersion])) {
throw new createHTTPError.NotFound('no such function');
}
// say goodbye if this is an unknown function
if (!(functionName in version[apiVersion])) {
throw new createHTTPError.NotFound("no such function");
}
if(!req.headers.authorization) {
throw new createHTTPError.Unauthorized('no or wrong API Key');
}
if (!req.headers.authorization) {
throw new createHTTPError.Unauthorized("no or wrong API Key");
}
try {
await jwtVerify(req.headers.authorization!.replace("Bearer ", ""), publicKeyExported!, {algorithms: ['RS256'],
requiredClaims: ["admin"]})
try {
await jwtVerify(
req.headers.authorization!.replace("Bearer ", ""),
publicKeyExported!,
{ algorithms: ["RS256"], requiredClaims: ["admin"] },
);
} catch (e) {
throw new createHTTPError.Unauthorized("no or wrong API Key");
}
} catch (e) {
throw new createHTTPError.Unauthorized('no or wrong API Key');
}
// sanitize any padIDs before continuing
if (fields.padID) {
fields.padID = await padManager.sanitizePadId(fields.padID);
}
// there was an 'else' here before - removed it to ensure
// that this sanitize step can't be circumvented by forcing
// the first branch to be taken
if (fields.padName) {
fields.padName = await padManager.sanitizePadId(fields.padName);
}
// put the function parameters in an array
// @ts-ignore
const functionParams = version[apiVersion][functionName].map(
(field) => fields[field],
);
// sanitize any padIDs before continuing
if (fields.padID) {
fields.padID = await padManager.sanitizePadId(fields.padID);
}
// there was an 'else' here before - removed it to ensure
// that this sanitize step can't be circumvented by forcing
// the first branch to be taken
if (fields.padName) {
fields.padName = await padManager.sanitizePadId(fields.padName);
}
// put the function parameters in an array
// @ts-ignore
const functionParams = version[apiVersion][functionName].map((field) => fields[field]);
// call the api function
return api[functionName].apply(this, functionParams);
// call the api function
return api[functionName].apply(this, functionParams);
};

View file

@ -1,4 +1,3 @@
'use strict';
/**
* Handles the export requests
*/
@ -20,15 +19,15 @@
* limitations under the License.
*/
const exporthtml = require('../utils/ExportHtml');
const exporttxt = require('../utils/ExportTxt');
const exportEtherpad = require('../utils/ExportEtherpad');
import fs from 'fs';
const settings = require('../utils/Settings');
import os from 'os';
const hooks = require('../../static/js/pluginfw/hooks');
import util from 'util';
const { checkValidRev } = require('../utils/checkValidRev');
const exporthtml = require("../utils/ExportHtml");
const exporttxt = require("../utils/ExportTxt");
const exportEtherpad = require("../utils/ExportEtherpad");
import fs from "fs";
const settings = require("../utils/Settings");
import os from "os";
const hooks = require("../../static/js/pluginfw/hooks");
import util from "util";
const { checkValidRev } = require("../utils/checkValidRev");
const fsp_writeFile = util.promisify(fs.writeFile);
const fsp_unlink = util.promisify(fs.unlink);
@ -43,84 +42,101 @@ const tempDirectory = os.tmpdir();
* @param {String} readOnlyId the read only id of the pad to export
* @param {String} type the type to export
*/
exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string, type:string) => {
// avoid naming the read-only file as the original pad's id
let fileName = readOnlyId ? readOnlyId : padId;
exports.doExport = async (
req: any,
res: any,
padId: string,
readOnlyId: string,
type: string,
) => {
// avoid naming the read-only file as the original pad's id
let fileName = readOnlyId ? readOnlyId : padId;
// allow fileName to be overwritten by a hook, the type type is kept static for security reasons
const hookFileName = await hooks.aCallFirst('exportFileName', padId);
// allow fileName to be overwritten by a hook, the type type is kept static for security reasons
const hookFileName = await hooks.aCallFirst("exportFileName", padId);
// if fileName is set then set it to the padId, note that fileName is returned as an array.
if (hookFileName.length) {
fileName = hookFileName;
}
// if fileName is set then set it to the padId, note that fileName is returned as an array.
if (hookFileName.length) {
fileName = hookFileName;
}
// tell the browser that this is a downloadable file
res.attachment(`${fileName}.${type}`);
// tell the browser that this is a downloadable file
res.attachment(`${fileName}.${type}`);
if (req.params.rev !== undefined) {
// ensure revision is a number
// modify req, as we use it in a later call to exportConvert
req.params.rev = checkValidRev(req.params.rev);
}
if (req.params.rev !== undefined) {
// ensure revision is a number
// modify req, as we use it in a later call to exportConvert
req.params.rev = checkValidRev(req.params.rev);
}
// if this is a plain text export, we can do this directly
// We have to over engineer this because tabs are stored as attributes and not plain text
if (type === 'etherpad') {
const pad = await exportEtherpad.getPadRaw(padId, readOnlyId);
res.send(pad);
} else if (type === 'txt') {
const txt = await exporttxt.getPadTXTDocument(padId, req.params.rev);
res.send(txt);
} else {
// render the html document
let html = await exporthtml.getPadHTMLDocument(padId, req.params.rev, readOnlyId);
// if this is a plain text export, we can do this directly
// We have to over engineer this because tabs are stored as attributes and not plain text
if (type === "etherpad") {
const pad = await exportEtherpad.getPadRaw(padId, readOnlyId);
res.send(pad);
} else if (type === "txt") {
const txt = await exporttxt.getPadTXTDocument(padId, req.params.rev);
res.send(txt);
} else {
// render the html document
let html = await exporthtml.getPadHTMLDocument(
padId,
req.params.rev,
readOnlyId,
);
// decide what to do with the html export
// decide what to do with the html export
// if this is a html export, we can send this from here directly
if (type === 'html') {
// do any final changes the plugin might want to make
const newHTML = await hooks.aCallFirst('exportHTMLSend', html);
if (newHTML.length) html = newHTML;
res.send(html);
return;
}
// if this is a html export, we can send this from here directly
if (type === "html") {
// do any final changes the plugin might want to make
const newHTML = await hooks.aCallFirst("exportHTMLSend", html);
if (newHTML.length) html = newHTML;
res.send(html);
return;
}
// else write the html export to a file
const randNum = Math.floor(Math.random() * 0xFFFFFFFF);
const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`;
await fsp_writeFile(srcFile, html);
// else write the html export to a file
const randNum = Math.floor(Math.random() * 0xffffffff);
const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`;
await fsp_writeFile(srcFile, html);
// ensure html can be collected by the garbage collector
html = null;
// ensure html can be collected by the garbage collector
html = null;
// send the convert job to the converter (abiword, libreoffice, ..)
const destFile = `${tempDirectory}/etherpad_export_${randNum}.${type}`;
// send the convert job to the converter (abiword, libreoffice, ..)
const destFile = `${tempDirectory}/etherpad_export_${randNum}.${type}`;
// Allow plugins to overwrite the convert in export process
const result = await hooks.aCallAll('exportConvert', {srcFile, destFile, req, res});
if (result.length > 0) {
// console.log("export handled by plugin", destFile);
} else {
const converter =
settings.soffice != null ? require('../utils/LibreOffice')
: settings.abiword != null ? require('../utils/Abiword')
: null;
await converter.convertFile(srcFile, destFile, type);
}
// Allow plugins to overwrite the convert in export process
const result = await hooks.aCallAll("exportConvert", {
srcFile,
destFile,
req,
res,
});
if (result.length > 0) {
// console.log("export handled by plugin", destFile);
} else {
const converter =
settings.soffice != null
? require("../utils/LibreOffice")
: settings.abiword != null
? require("../utils/Abiword")
: null;
await converter.convertFile(srcFile, destFile, type);
}
// send the file
await res.sendFile(destFile, null);
// send the file
await res.sendFile(destFile, null);
// clean up temporary files
await fsp_unlink(srcFile);
// clean up temporary files
await fsp_unlink(srcFile);
// 100ms delay to accommodate for slow windows fs
if (os.type().indexOf('Windows') > -1) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
// 100ms delay to accommodate for slow windows fs
if (os.type().indexOf("Windows") > -1) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await fsp_unlink(destFile);
}
await fsp_unlink(destFile);
}
};

View file

@ -1,4 +1,3 @@
'use strict';
/**
* Handles the import requests
*/
@ -21,53 +20,53 @@
* limitations under the License.
*/
const padManager = require('../db/PadManager');
const padMessageHandler = require('./PadMessageHandler');
import {promises as fs} from 'fs';
import path from 'path';
const settings = require('../utils/Settings');
const {Formidable} = require('formidable');
import os from 'os';
const importHtml = require('../utils/ImportHtml');
const importEtherpad = require('../utils/ImportEtherpad');
import log4js from 'log4js';
const hooks = require('../../static/js/pluginfw/hooks.js');
const padManager = require("../db/PadManager");
const padMessageHandler = require("./PadMessageHandler");
import { promises as fs } from "fs";
import path from "path";
const settings = require("../utils/Settings");
const { Formidable } = require("formidable");
import os from "os";
const importHtml = require("../utils/ImportHtml");
const importEtherpad = require("../utils/ImportEtherpad");
import log4js from "log4js";
const hooks = require("../../static/js/pluginfw/hooks.js");
const logger = log4js.getLogger('ImportHandler');
const logger = log4js.getLogger("ImportHandler");
// `status` must be a string supported by `importErrorMessage()` in `src/static/js/pad_impexp.js`.
class ImportError extends Error {
status: string;
constructor(status: string, ...args:any) {
super(...args);
if (Error.captureStackTrace) Error.captureStackTrace(this, ImportError);
this.name = 'ImportError';
this.status = status;
const msg = this.message == null ? '' : String(this.message);
if (status !== '') this.message = msg === '' ? status : `${status}: ${msg}`;
}
status: string;
constructor(status: string, ...args: any) {
super(...args);
if (Error.captureStackTrace) Error.captureStackTrace(this, ImportError);
this.name = "ImportError";
this.status = status;
const msg = this.message == null ? "" : String(this.message);
if (status !== "") this.message = msg === "" ? status : `${status}: ${msg}`;
}
}
const rm = async (path: string) => {
try {
await fs.unlink(path);
} catch (err:any) {
if (err.code !== 'ENOENT') throw err;
}
try {
await fs.unlink(path);
} catch (err: any) {
if (err.code !== "ENOENT") throw err;
}
};
let converter:any = null;
let exportExtension = 'htm';
let converter: any = null;
let exportExtension = "htm";
// load abiword only if it is enabled and if soffice is disabled
if (settings.abiword != null && settings.soffice == null) {
converter = require('../utils/Abiword');
converter = require("../utils/Abiword");
}
// load soffice only if it is enabled
if (settings.soffice != null) {
converter = require('../utils/LibreOffice');
exportExtension = 'html';
converter = require("../utils/LibreOffice");
exportExtension = "html";
}
const tmpDirectory = os.tmpdir();
@ -79,163 +78,193 @@ const tmpDirectory = os.tmpdir();
* @param {String} padId the pad id to export
* @param {String} authorId the author id to use for the import
*/
const doImport = async (req:any, res:any, padId:string, authorId:string) => {
// pipe to a file
// convert file to html via abiword or soffice
// set html in the pad
const randNum = Math.floor(Math.random() * 0xFFFFFFFF);
const doImport = async (
req: any,
res: any,
padId: string,
authorId: string,
) => {
// pipe to a file
// convert file to html via abiword or soffice
// set html in the pad
const randNum = Math.floor(Math.random() * 0xffffffff);
// setting flag for whether to use converter or not
let useConverter = (converter != null);
// setting flag for whether to use converter or not
let useConverter = converter != null;
const form = new Formidable({
keepExtensions: true,
uploadDir: tmpDirectory,
maxFileSize: settings.importMaxFileSize,
});
const form = new Formidable({
keepExtensions: true,
uploadDir: tmpDirectory,
maxFileSize: settings.importMaxFileSize,
});
let srcFile;
let files;
let fields;
try {
[fields, files] = await form.parse(req);
} catch (err:any) {
logger.warn(`Import failed due to form error: ${err.stack || err}`);
if (err.code === Formidable.formidableErrors.biggerThanMaxFileSize) {
throw new ImportError('maxFileSize');
}
throw new ImportError('uploadFailed');
}
if (!files.file) {
logger.warn('Import failed because form had no file');
throw new ImportError('uploadFailed');
} else {
srcFile = files.file[0].filepath;
}
let srcFile;
let files;
let fields;
try {
[fields, files] = await form.parse(req);
} catch (err: any) {
logger.warn(`Import failed due to form error: ${err.stack || err}`);
if (err.code === Formidable.formidableErrors.biggerThanMaxFileSize) {
throw new ImportError("maxFileSize");
}
throw new ImportError("uploadFailed");
}
if (!files.file) {
logger.warn("Import failed because form had no file");
throw new ImportError("uploadFailed");
} else {
srcFile = files.file[0].filepath;
}
// ensure this is a file ending we know, else we change the file ending to .txt
// this allows us to accept source code files like .c or .java
const fileEnding = path.extname(files.file[0].originalFilename).toLowerCase();
const knownFileEndings =
['.txt', '.doc', '.docx', '.pdf', '.odt', '.html', '.htm', '.etherpad', '.rtf'];
const fileEndingUnknown = (knownFileEndings.indexOf(fileEnding) < 0);
// ensure this is a file ending we know, else we change the file ending to .txt
// this allows us to accept source code files like .c or .java
const fileEnding = path.extname(files.file[0].originalFilename).toLowerCase();
const knownFileEndings = [
".txt",
".doc",
".docx",
".pdf",
".odt",
".html",
".htm",
".etherpad",
".rtf",
];
const fileEndingUnknown = knownFileEndings.indexOf(fileEnding) < 0;
if (fileEndingUnknown) {
// the file ending is not known
if (fileEndingUnknown) {
// the file ending is not known
if (settings.allowUnknownFileEnds === true) {
// we need to rename this file with a .txt ending
const oldSrcFile = srcFile;
if (settings.allowUnknownFileEnds === true) {
// we need to rename this file with a .txt ending
const oldSrcFile = srcFile;
srcFile = path.join(path.dirname(srcFile), `${path.basename(srcFile, fileEnding)}.txt`);
await fs.rename(oldSrcFile, srcFile);
} else {
logger.warn(`Not allowing unknown file type to be imported: ${fileEnding}`);
throw new ImportError('uploadFailed');
}
}
srcFile = path.join(
path.dirname(srcFile),
`${path.basename(srcFile, fileEnding)}.txt`,
);
await fs.rename(oldSrcFile, srcFile);
} else {
logger.warn(
`Not allowing unknown file type to be imported: ${fileEnding}`,
);
throw new ImportError("uploadFailed");
}
}
const destFile = path.join(tmpDirectory, `etherpad_import_${randNum}.${exportExtension}`);
const context = {srcFile, destFile, fileEnding, padId, ImportError};
const importHandledByPlugin = (await hooks.aCallAll('import', context)).some((x:string) => x);
const fileIsEtherpad = (fileEnding === '.etherpad');
const fileIsHTML = (fileEnding === '.html' || fileEnding === '.htm');
const fileIsTXT = (fileEnding === '.txt');
const destFile = path.join(
tmpDirectory,
`etherpad_import_${randNum}.${exportExtension}`,
);
const context = { srcFile, destFile, fileEnding, padId, ImportError };
const importHandledByPlugin = (await hooks.aCallAll("import", context)).some(
(x: string) => x,
);
const fileIsEtherpad = fileEnding === ".etherpad";
const fileIsHTML = fileEnding === ".html" || fileEnding === ".htm";
const fileIsTXT = fileEnding === ".txt";
let directDatabaseAccess = false;
if (fileIsEtherpad) {
// Use '\n' to avoid the default pad text if the pad doesn't yet exist.
const pad = await padManager.getPad(padId, '\n', authorId);
const headCount = pad.head;
if (headCount >= 10) {
logger.warn('Aborting direct database import attempt of a pad that already has content');
throw new ImportError('padHasData');
}
const text = await fs.readFile(srcFile, 'utf8');
directDatabaseAccess = true;
await importEtherpad.setPadRaw(padId, text, authorId);
}
let directDatabaseAccess = false;
if (fileIsEtherpad) {
// Use '\n' to avoid the default pad text if the pad doesn't yet exist.
const pad = await padManager.getPad(padId, "\n", authorId);
const headCount = pad.head;
if (headCount >= 10) {
logger.warn(
"Aborting direct database import attempt of a pad that already has content",
);
throw new ImportError("padHasData");
}
const text = await fs.readFile(srcFile, "utf8");
directDatabaseAccess = true;
await importEtherpad.setPadRaw(padId, text, authorId);
}
// convert file to html if necessary
if (!importHandledByPlugin && !directDatabaseAccess) {
if (fileIsTXT) {
// Don't use converter for text files
useConverter = false;
}
// convert file to html if necessary
if (!importHandledByPlugin && !directDatabaseAccess) {
if (fileIsTXT) {
// Don't use converter for text files
useConverter = false;
}
// See https://github.com/ether/etherpad-lite/issues/2572
if (fileIsHTML || !useConverter) {
// if no converter only rename
await fs.rename(srcFile, destFile);
} else {
try {
await converter.convertFile(srcFile, destFile, exportExtension);
} catch (err:any) {
logger.warn(`Converting Error: ${err.stack || err}`);
throw new ImportError('convertFailed');
}
}
}
// See https://github.com/ether/etherpad-lite/issues/2572
if (fileIsHTML || !useConverter) {
// if no converter only rename
await fs.rename(srcFile, destFile);
} else {
try {
await converter.convertFile(srcFile, destFile, exportExtension);
} catch (err: any) {
logger.warn(`Converting Error: ${err.stack || err}`);
throw new ImportError("convertFailed");
}
}
}
if (!useConverter && !directDatabaseAccess) {
// Read the file with no encoding for raw buffer access.
const buf = await fs.readFile(destFile);
if (!useConverter && !directDatabaseAccess) {
// Read the file with no encoding for raw buffer access.
const buf = await fs.readFile(destFile);
// Check if there are only ascii chars in the uploaded file
const isAscii = !Array.prototype.some.call(buf, (c) => (c > 240));
// Check if there are only ascii chars in the uploaded file
const isAscii = !Array.prototype.some.call(buf, (c) => c > 240);
if (!isAscii) {
logger.warn('Attempt to import non-ASCII file');
throw new ImportError('uploadFailed');
}
}
if (!isAscii) {
logger.warn("Attempt to import non-ASCII file");
throw new ImportError("uploadFailed");
}
}
// Use '\n' to avoid the default pad text if the pad doesn't yet exist.
let pad = await padManager.getPad(padId, '\n', authorId);
// Use '\n' to avoid the default pad text if the pad doesn't yet exist.
let pad = await padManager.getPad(padId, "\n", authorId);
// read the text
let text;
// read the text
let text;
if (!directDatabaseAccess) {
text = await fs.readFile(destFile, 'utf8');
if (!directDatabaseAccess) {
text = await fs.readFile(destFile, "utf8");
// node on windows has a delay on releasing of the file lock.
// We add a 100ms delay to work around this
if (os.type().indexOf('Windows') > -1) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
// node on windows has a delay on releasing of the file lock.
// We add a 100ms delay to work around this
if (os.type().indexOf("Windows") > -1) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
// change text of the pad and broadcast the changeset
if (!directDatabaseAccess) {
if (importHandledByPlugin || useConverter || fileIsHTML) {
try {
await importHtml.setPadHTML(pad, text, authorId);
} catch (err:any) {
logger.warn(`Error importing, possibly caused by malformed HTML: ${err.stack || err}`);
}
} else {
await pad.setText(text, authorId);
}
}
// change text of the pad and broadcast the changeset
if (!directDatabaseAccess) {
if (importHandledByPlugin || useConverter || fileIsHTML) {
try {
await importHtml.setPadHTML(pad, text, authorId);
} catch (err: any) {
logger.warn(
`Error importing, possibly caused by malformed HTML: ${
err.stack || err
}`,
);
}
} else {
await pad.setText(text, authorId);
}
}
// Load the Pad into memory then broadcast updates to all clients
padManager.unloadPad(padId);
pad = await padManager.getPad(padId, '\n', authorId);
padManager.unloadPad(padId);
// Load the Pad into memory then broadcast updates to all clients
padManager.unloadPad(padId);
pad = await padManager.getPad(padId, "\n", authorId);
padManager.unloadPad(padId);
// Direct database access means a pad user should reload the pad and not attempt to receive
// updated pad data.
if (directDatabaseAccess) return true;
// Direct database access means a pad user should reload the pad and not attempt to receive
// updated pad data.
if (directDatabaseAccess) return true;
// tell clients to update
await padMessageHandler.updatePadClients(pad);
// tell clients to update
await padMessageHandler.updatePadClients(pad);
// clean up temporary files
rm(srcFile);
rm(destFile);
// clean up temporary files
rm(srcFile);
rm(destFile);
return false;
return false;
};
/**
@ -246,19 +275,22 @@ const doImport = async (req:any, res:any, padId:string, authorId:string) => {
* @param {String} authorId the author id to use for the import
* @return {Promise<void>} a promise
*/
exports.doImport = async (req:any, res:any, padId:string, authorId:string = '') => {
let httpStatus = 200;
let code = 0;
let message = 'ok';
let directDatabaseAccess;
try {
directDatabaseAccess = await doImport(req, res, padId, authorId);
} catch (err:any) {
const known = err instanceof ImportError && err.status;
if (!known) logger.error(`Internal error during import: ${err.stack || err}`);
httpStatus = known ? 400 : 500;
code = known ? 1 : 2;
message = known ? err.status : 'internalError';
}
res.status(httpStatus).json({code, message, data: {directDatabaseAccess}});
exports.doImport = async (req: any, res: any, padId: string, authorId = "") => {
let httpStatus = 200;
let code = 0;
let message = "ok";
let directDatabaseAccess;
try {
directDatabaseAccess = await doImport(req, res, padId, authorId);
} catch (err: any) {
const known = err instanceof ImportError && err.status;
if (!known)
logger.error(`Internal error during import: ${err.stack || err}`);
httpStatus = known ? 400 : 500;
code = known ? 1 : 2;
message = known ? err.status : "internalError";
}
res
.status(httpStatus)
.json({ code, message, data: { directDatabaseAccess } });
};

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
'use strict';
/**
* This is the Socket.IO Router. It routes the Messages between the
* components of the Server. The components are at the moment: pad and timeslider
@ -20,87 +19,98 @@
* limitations under the License.
*/
import {MapArrayType} from "../types/MapType";
import {SocketModule} from "../types/SocketModule";
const log4js = require('log4js');
const settings = require('../utils/Settings');
const stats = require('../../node/stats')
import type { MapArrayType } from "../types/MapType";
import type { SocketModule } from "../types/SocketModule";
const log4js = require("log4js");
const settings = require("../utils/Settings");
const stats = require("../../node/stats");
const logger = log4js.getLogger('socket.io');
const logger = log4js.getLogger("socket.io");
/**
* Saves all components
* key is the component name
* value is the component module
*/
const components:MapArrayType<any> = {};
const components: MapArrayType<any> = {};
let io:any;
let io: any;
/** adds a component
* @param {string} moduleName
* @param {Module} module
*/
exports.addComponent = (moduleName: string, module: SocketModule) => {
if (module == null) return exports.deleteComponent(moduleName);
components[moduleName] = module;
module.setSocketIO(io);
if (module == null) return exports.deleteComponent(moduleName);
components[moduleName] = module;
module.setSocketIO(io);
};
/**
* removes a component
* @param {Module} moduleName
*/
exports.deleteComponent = (moduleName: string) => { delete components[moduleName]; };
exports.deleteComponent = (moduleName: string) => {
delete components[moduleName];
};
/**
* sets the socket.io and adds event functions for routing
* @param {Object} _io the socket.io instance
*/
exports.setSocketIO = (_io:any) => {
io = _io;
exports.setSocketIO = (_io: any) => {
io = _io;
io.sockets.on('connection', (socket:any) => {
const ip = settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip;
logger.debug(`${socket.id} connected from IP ${ip}`);
io.sockets.on("connection", (socket: any) => {
const ip = settings.disableIPlogging ? "ANONYMOUS" : socket.request.ip;
logger.debug(`${socket.id} connected from IP ${ip}`);
// wrap the original send function to log the messages
socket._send = socket.send;
socket.send = (message: string) => {
logger.debug(`to ${socket.id}: ${JSON.stringify(message)}`);
socket._send(message);
};
// wrap the original send function to log the messages
socket._send = socket.send;
socket.send = (message: string) => {
logger.debug(`to ${socket.id}: ${JSON.stringify(message)}`);
socket._send(message);
};
// tell all components about this connect
for (const i of Object.keys(components)) {
components[i].handleConnect(socket);
}
// tell all components about this connect
for (const i of Object.keys(components)) {
components[i].handleConnect(socket);
}
socket.on('message', (message: any, ack: any = () => {}) => (async () => {
if (!message.component || !components[message.component]) {
throw new Error(`unknown message component: ${message.component}`);
}
logger.debug(`from ${socket.id}:`, message);
return await components[message.component].handleMessage(socket, message);
})().then(
(val) => ack(null, val),
(err) => {
logger.error(
`Error handling ${message.component} message from ${socket.id}: ${err.stack || err}`);
ack({name: err.name, message: err.message}); // socket.io can't handle Error objects.
}));
socket.on("message", (message: any, ack: any = () => {}) =>
(async () => {
if (!message.component || !components[message.component]) {
throw new Error(`unknown message component: ${message.component}`);
}
logger.debug(`from ${socket.id}:`, message);
return await components[message.component].handleMessage(
socket,
message,
);
})().then(
(val) => ack(null, val),
(err) => {
logger.error(
`Error handling ${message.component} message from ${socket.id}: ${
err.stack || err
}`,
);
ack({ name: err.name, message: err.message }); // socket.io can't handle Error objects.
},
),
);
socket.on('disconnect', (reason: string) => {
logger.debug(`${socket.id} disconnected: ${reason}`);
// store the lastDisconnect as a timestamp, this is useful if you want to know
// when the last user disconnected. If your activePads is 0 and totalUsers is 0
// you can say, if there has been no active pads or active users for 10 minutes
// this instance can be brought out of a scaling cluster.
stats.gauge('lastDisconnect', () => Date.now());
// tell all components about this disconnect
for (const i of Object.keys(components)) {
components[i].handleDisconnect(socket);
}
});
});
socket.on("disconnect", (reason: string) => {
logger.debug(`${socket.id} disconnected: ${reason}`);
// store the lastDisconnect as a timestamp, this is useful if you want to know
// when the last user disconnected. If your activePads is 0 and totalUsers is 0
// you can say, if there has been no active pads or active users for 10 minutes
// this instance can be brought out of a scaling cluster.
stats.gauge("lastDisconnect", () => Date.now());
// tell all components about this disconnect
for (const i of Object.keys(components)) {
components[i].handleDisconnect(socket);
}
});
});
};

View file

@ -1,261 +1,294 @@
'use strict';
import type { Socket } from "node:net";
import type { MapArrayType } from "../types/MapType";
import {Socket} from "node:net";
import type {MapArrayType} from "../types/MapType";
import _ from 'underscore';
import events from "events";
import fs from "fs";
// @ts-ignore
import cookieParser from 'cookie-parser';
import events from 'events';
import express from 'express';
import cookieParser from "cookie-parser";
import express from "express";
// @ts-ignore
import expressSession from 'express-session';
import fs from 'fs';
const hooks = require('../../static/js/pluginfw/hooks');
import log4js from 'log4js';
const SessionStore = require('../db/SessionStore');
const settings = require('../utils/Settings');
const stats = require('../stats')
import util from 'util';
const webaccess = require('./express/webaccess');
import expressSession from "express-session";
import _ from "underscore";
const hooks = require("../../static/js/pluginfw/hooks");
import log4js from "log4js";
const SessionStore = require("../db/SessionStore");
const settings = require("../utils/Settings");
const stats = require("../stats");
import util from "util";
const webaccess = require("./express/webaccess");
import SecretRotator from '../security/SecretRotator';
import SecretRotator from "../security/SecretRotator";
let secretRotator: SecretRotator|null = null;
const logger = log4js.getLogger('http');
let serverName:string;
let sessionStore: { shutdown: () => void; } | null;
const sockets:Set<Socket> = new Set();
let secretRotator: SecretRotator | null = null;
const logger = log4js.getLogger("http");
let serverName: string;
let sessionStore: { shutdown: () => void } | null;
const sockets: Set<Socket> = new Set();
const socketsEvents = new events.EventEmitter();
const startTime = stats.settableGauge('httpStartTime');
const startTime = stats.settableGauge("httpStartTime");
exports.server = null;
const closeServer = async () => {
if (exports.server != null) {
logger.info('Closing HTTP server...');
// Call exports.server.close() to reject new connections but don't await just yet because the
// Promise won't resolve until all preexisting connections are closed.
const p = util.promisify(exports.server.close.bind(exports.server))();
await hooks.aCallAll('expressCloseServer');
// Give existing connections some time to close on their own before forcibly terminating. The
// time should be long enough to avoid interrupting most preexisting transmissions but short
// enough to avoid a noticeable outage.
const timeout = setTimeout(async () => {
logger.info(`Forcibly terminating remaining ${sockets.size} HTTP connections...`);
for (const socket of sockets) socket.destroy(new Error('HTTP server is closing'));
}, 5000);
let lastLogged = 0;
while (sockets.size > 0 && !settings.enableAdminUITests) {
if (Date.now() - lastLogged > 1000) { // Rate limit to avoid filling logs.
logger.info(`Waiting for ${sockets.size} HTTP clients to disconnect...`);
lastLogged = Date.now();
}
await events.once(socketsEvents, 'updated');
}
await p;
clearTimeout(timeout);
exports.server = null;
startTime.setValue(0);
logger.info('HTTP server closed');
}
if (sessionStore) sessionStore.shutdown();
sessionStore = null;
if (secretRotator) secretRotator.stop();
secretRotator = null;
if (exports.server != null) {
logger.info("Closing HTTP server...");
// Call exports.server.close() to reject new connections but don't await just yet because the
// Promise won't resolve until all preexisting connections are closed.
const p = util.promisify(exports.server.close.bind(exports.server))();
await hooks.aCallAll("expressCloseServer");
// Give existing connections some time to close on their own before forcibly terminating. The
// time should be long enough to avoid interrupting most preexisting transmissions but short
// enough to avoid a noticeable outage.
const timeout = setTimeout(async () => {
logger.info(
`Forcibly terminating remaining ${sockets.size} HTTP connections...`,
);
for (const socket of sockets)
socket.destroy(new Error("HTTP server is closing"));
}, 5000);
let lastLogged = 0;
while (sockets.size > 0 && !settings.enableAdminUITests) {
if (Date.now() - lastLogged > 1000) {
// Rate limit to avoid filling logs.
logger.info(
`Waiting for ${sockets.size} HTTP clients to disconnect...`,
);
lastLogged = Date.now();
}
await events.once(socketsEvents, "updated");
}
await p;
clearTimeout(timeout);
exports.server = null;
startTime.setValue(0);
logger.info("HTTP server closed");
}
if (sessionStore) sessionStore.shutdown();
sessionStore = null;
if (secretRotator) secretRotator.stop();
secretRotator = null;
};
exports.createServer = async () => {
console.log('Report bugs at https://github.com/ether/etherpad-lite/issues');
console.log("Report bugs at https://github.com/ether/etherpad-lite/issues");
serverName = `Etherpad ${settings.getGitCommit()} (https://etherpad.org)`;
serverName = `Etherpad ${settings.getGitCommit()} (https://etherpad.org)`;
console.log(`Your Etherpad version is ${settings.getEpVersion()} (${settings.getGitCommit()})`);
console.log(
`Your Etherpad version is ${settings.getEpVersion()} (${settings.getGitCommit()})`,
);
await exports.restartServer();
await exports.restartServer();
if (settings.ip === '') {
// using Unix socket for connectivity
console.log(`You can access your Etherpad instance using the Unix socket at ${settings.port}`);
} else {
console.log(`You can access your Etherpad instance at http://${settings.ip}:${settings.port}/`);
}
if (settings.ip === "") {
// using Unix socket for connectivity
console.log(
`You can access your Etherpad instance using the Unix socket at ${settings.port}`,
);
} else {
console.log(
`You can access your Etherpad instance at http://${settings.ip}:${settings.port}/`,
);
}
if (!_.isEmpty(settings.users)) {
console.log(`The plugin admin page is at http://${settings.ip}:${settings.port}/admin/plugins`);
} else {
console.warn('Admin username and password not set in settings.json. ' +
'To access admin please uncomment and edit "users" in settings.json');
}
if (!_.isEmpty(settings.users)) {
console.log(
`The plugin admin page is at http://${settings.ip}:${settings.port}/admin/plugins`,
);
} else {
console.warn(
"Admin username and password not set in settings.json. " +
'To access admin please uncomment and edit "users" in settings.json',
);
}
const env = process.env.NODE_ENV || 'development';
const env = process.env.NODE_ENV || "development";
if (env !== 'production') {
console.warn('Etherpad is running in Development mode. This mode is slower for users and ' +
'less secure than production mode. You should set the NODE_ENV environment ' +
'variable to production by using: export NODE_ENV=production');
}
if (env !== "production") {
console.warn(
"Etherpad is running in Development mode. This mode is slower for users and " +
"less secure than production mode. You should set the NODE_ENV environment " +
"variable to production by using: export NODE_ENV=production",
);
}
};
exports.restartServer = async () => {
await closeServer();
await closeServer();
const app = express(); // New syntax for express v3
const app = express(); // New syntax for express v3
if (settings.ssl) {
console.log('SSL -- enabled');
console.log(`SSL -- server key file: ${settings.ssl.key}`);
console.log(`SSL -- Certificate Authority's certificate file: ${settings.ssl.cert}`);
if (settings.ssl) {
console.log("SSL -- enabled");
console.log(`SSL -- server key file: ${settings.ssl.key}`);
console.log(
`SSL -- Certificate Authority's certificate file: ${settings.ssl.cert}`,
);
const options: MapArrayType<any> = {
key: fs.readFileSync(settings.ssl.key),
cert: fs.readFileSync(settings.ssl.cert),
};
const options: MapArrayType<any> = {
key: fs.readFileSync(settings.ssl.key),
cert: fs.readFileSync(settings.ssl.cert),
};
if (settings.ssl.ca) {
options.ca = [];
for (let i = 0; i < settings.ssl.ca.length; i++) {
const caFileName = settings.ssl.ca[i];
options.ca.push(fs.readFileSync(caFileName));
}
}
if (settings.ssl.ca) {
options.ca = [];
for (let i = 0; i < settings.ssl.ca.length; i++) {
const caFileName = settings.ssl.ca[i];
options.ca.push(fs.readFileSync(caFileName));
}
}
const https = require('https');
exports.server = https.createServer(options, app);
} else {
const http = require('http');
exports.server = http.createServer(app);
}
const https = require("https");
exports.server = https.createServer(options, app);
} else {
const http = require("http");
exports.server = http.createServer(app);
}
app.use((req, res, next) => {
// res.header("X-Frame-Options", "deny"); // breaks embedded pads
if (settings.ssl) {
// we use SSL
res.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
app.use((req, res, next) => {
// res.header("X-Frame-Options", "deny"); // breaks embedded pads
if (settings.ssl) {
// we use SSL
res.header(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
);
}
// Stop IE going into compatability mode
// https://github.com/ether/etherpad-lite/issues/2547
res.header('X-UA-Compatible', 'IE=Edge,chrome=1');
// Stop IE going into compatability mode
// https://github.com/ether/etherpad-lite/issues/2547
res.header("X-UA-Compatible", "IE=Edge,chrome=1");
// Enable a strong referrer policy. Same-origin won't drop Referers when
// loading local resources, but it will drop them when loading foreign resources.
// It's still a last bastion of referrer security. External URLs should be
// already marked with rel="noreferer" and user-generated content pages are already
// marked with <meta name="referrer" content="no-referrer">
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
// https://github.com/ether/etherpad-lite/pull/3636
res.header('Referrer-Policy', 'same-origin');
// Enable a strong referrer policy. Same-origin won't drop Referers when
// loading local resources, but it will drop them when loading foreign resources.
// It's still a last bastion of referrer security. External URLs should be
// already marked with rel="noreferer" and user-generated content pages are already
// marked with <meta name="referrer" content="no-referrer">
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
// https://github.com/ether/etherpad-lite/pull/3636
res.header("Referrer-Policy", "same-origin");
// send git version in the Server response header if exposeVersion is true.
if (settings.exposeVersion) {
res.header('Server', serverName);
}
// send git version in the Server response header if exposeVersion is true.
if (settings.exposeVersion) {
res.header("Server", serverName);
}
next();
});
next();
});
if (settings.trustProxy) {
/*
* If 'trust proxy' === true, the clients IP address in req.ip will be the
* left-most entry in the X-Forwarded-* header.
*
* Source: https://expressjs.com/en/guide/behind-proxies.html
*/
app.enable('trust proxy');
}
if (settings.trustProxy) {
/*
* If 'trust proxy' === true, the clients IP address in req.ip will be the
* left-most entry in the X-Forwarded-* header.
*
* Source: https://expressjs.com/en/guide/behind-proxies.html
*/
app.enable("trust proxy");
}
// Measure response time
app.use((req, res, next) => {
const stopWatch = stats.timer('httpRequests').start();
const sendFn = res.send.bind(res);
res.send = (...args) => { stopWatch.end(); return sendFn(...args); };
next();
});
// Measure response time
app.use((req, res, next) => {
const stopWatch = stats.timer("httpRequests").start();
const sendFn = res.send.bind(res);
res.send = (...args) => {
stopWatch.end();
return sendFn(...args);
};
next();
});
// If the log level specified in the config file is WARN or ERROR the application server never
// starts listening to requests as reported in issue #158. Not installing the log4js connect
// logger when the log level has a higher severity than INFO since it would not log at that level
// anyway.
if (!(settings.loglevel === 'WARN' && settings.loglevel === 'ERROR')) {
app.use(log4js.connectLogger(logger, {
level: log4js.levels.DEBUG.levelStr,
format: ':status, :method :url',
}));
}
// If the log level specified in the config file is WARN or ERROR the application server never
// starts listening to requests as reported in issue #158. Not installing the log4js connect
// logger when the log level has a higher severity than INFO since it would not log at that level
// anyway.
if (!(settings.loglevel === "WARN" && settings.loglevel === "ERROR")) {
app.use(
log4js.connectLogger(logger, {
level: log4js.levels.DEBUG.levelStr,
format: ":status, :method :url",
}),
);
}
const {keyRotationInterval, sessionLifetime} = settings.cookie;
let secret = settings.sessionKey;
if (keyRotationInterval && sessionLifetime) {
secretRotator = new SecretRotator(
'expressSessionSecrets', keyRotationInterval, sessionLifetime, settings.sessionKey);
await secretRotator.start();
secret = secretRotator.secrets;
}
if (!secret) throw new Error('missing cookie signing secret');
const { keyRotationInterval, sessionLifetime } = settings.cookie;
let secret = settings.sessionKey;
if (keyRotationInterval && sessionLifetime) {
secretRotator = new SecretRotator(
"expressSessionSecrets",
keyRotationInterval,
sessionLifetime,
settings.sessionKey,
);
await secretRotator.start();
secret = secretRotator.secrets;
}
if (!secret) throw new Error("missing cookie signing secret");
app.use(cookieParser(secret, {}));
app.use(cookieParser(secret, {}));
sessionStore = new SessionStore(settings.cookie.sessionRefreshInterval);
exports.sessionMiddleware = expressSession({
propagateTouch: true,
rolling: true,
secret,
store: sessionStore,
resave: false,
saveUninitialized: false,
// Set the cookie name to a javascript identifier compatible string. Makes code handling it
// cleaner :)
name: 'express_sid',
cookie: {
maxAge: sessionLifetime || null, // Convert 0 to null.
sameSite: settings.cookie.sameSite,
sessionStore = new SessionStore(settings.cookie.sessionRefreshInterval);
exports.sessionMiddleware = expressSession({
propagateTouch: true,
rolling: true,
secret,
store: sessionStore,
resave: false,
saveUninitialized: false,
// Set the cookie name to a javascript identifier compatible string. Makes code handling it
// cleaner :)
name: "express_sid",
cookie: {
maxAge: sessionLifetime || null, // Convert 0 to null.
sameSite: settings.cookie.sameSite,
// The automatic express-session mechanism for determining if the application is being served
// over ssl is similar to the one used for setting the language cookie, which check if one of
// these conditions is true:
//
// 1. we are directly serving the nodejs application over SSL, using the "ssl" options in
// settings.json
//
// 2. we are serving the nodejs application in plaintext, but we are using a reverse proxy
// that terminates SSL for us. In this case, the user has to set trustProxy = true in
// settings.json, and the information wheter the application is over SSL or not will be
// extracted from the X-Forwarded-Proto HTTP header
//
// Please note that this will not be compatible with applications being served over http and
// https at the same time.
//
// reference: https://github.com/expressjs/session/blob/v1.17.0/README.md#cookiesecure
secure: 'auto',
},
});
// The automatic express-session mechanism for determining if the application is being served
// over ssl is similar to the one used for setting the language cookie, which check if one of
// these conditions is true:
//
// 1. we are directly serving the nodejs application over SSL, using the "ssl" options in
// settings.json
//
// 2. we are serving the nodejs application in plaintext, but we are using a reverse proxy
// that terminates SSL for us. In this case, the user has to set trustProxy = true in
// settings.json, and the information wheter the application is over SSL or not will be
// extracted from the X-Forwarded-Proto HTTP header
//
// Please note that this will not be compatible with applications being served over http and
// https at the same time.
//
// reference: https://github.com/expressjs/session/blob/v1.17.0/README.md#cookiesecure
secure: "auto",
},
});
// Give plugins an opportunity to install handlers/middleware before the express-session
// middleware. This allows plugins to avoid creating an express-session record in the database
// when it is not needed (e.g., public static content).
await hooks.aCallAll('expressPreSession', {app});
app.use(exports.sessionMiddleware);
// Give plugins an opportunity to install handlers/middleware before the express-session
// middleware. This allows plugins to avoid creating an express-session record in the database
// when it is not needed (e.g., public static content).
await hooks.aCallAll("expressPreSession", { app });
app.use(exports.sessionMiddleware);
app.use(webaccess.checkAccess);
app.use(webaccess.checkAccess);
await Promise.all([
hooks.aCallAll('expressConfigure', {app}),
hooks.aCallAll('expressCreateServer', {app, server: exports.server}),
]);
exports.server.on('connection', (socket:Socket) => {
sockets.add(socket);
socketsEvents.emit('updated');
socket.on('close', () => {
sockets.delete(socket);
socketsEvents.emit('updated');
});
});
await util.promisify(exports.server.listen).bind(exports.server)(settings.port, settings.ip);
startTime.setValue(Date.now());
logger.info('HTTP server listening for connections');
await Promise.all([
hooks.aCallAll("expressConfigure", { app }),
hooks.aCallAll("expressCreateServer", { app, server: exports.server }),
]);
exports.server.on("connection", (socket: Socket) => {
sockets.add(socket);
socketsEvents.emit("updated");
socket.on("close", () => {
sockets.delete(socket);
socketsEvents.emit("updated");
});
});
await util.promisify(exports.server.listen).bind(exports.server)(
settings.port,
settings.ip,
);
startTime.setValue(Date.now());
logger.info("HTTP server listening for connections");
};
exports.shutdown = async (hookName:string, context: any) => {
await closeServer();
exports.shutdown = async (hookName: string, context: any) => {
await closeServer();
};

View file

@ -1,11 +1,6 @@
'use strict';
import {ArgsExpressType} from "../../types/ArgsExpressType";
import path from "path";
import fs from "fs";
import express from "express";
const settings = require('ep_etherpad-lite/node/utils/Settings');
const ADMIN_PATH = path.join(settings.root, 'src', 'templates', 'admin');
import type { ArgsExpressType } from "../../types/ArgsExpressType";
/**
* Add the admin navigation link
@ -14,13 +9,24 @@ const ADMIN_PATH = path.join(settings.root, 'src', 'templates', 'admin');
* @param {Function} cb the callback function
* @return {*}
*/
exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function): any => {
args.app.use('/admin/', express.static(path.join(__dirname, '../../../templates/admin'), {maxAge: 1000 * 60 * 60 * 24}));
args.app.get('/admin/*', (_request:any, response:any)=>{
response.sendFile(path.resolve(__dirname,'../../../templates/admin', 'index.html'));
} )
args.app.get('/admin', (req:any, res:any, next:Function) => {
if ('/' !== req.path[req.path.length - 1]) return res.redirect('./admin/');
})
return cb();
exports.expressCreateServer = (
hookName: string,
args: ArgsExpressType,
cb: Function,
): any => {
args.app.use(
"/admin/",
express.static(path.join(__dirname, "../../../templates/admin"), {
maxAge: 1000 * 60 * 60 * 24,
}),
);
args.app.get("/admin/*", (_request: any, response: any) => {
response.sendFile(
path.resolve(__dirname, "../../../templates/admin", "index.html"),
);
});
args.app.get("/admin", (req: any, res: any, next: Function) => {
if ("/" !== req.path[req.path.length - 1]) return res.redirect("./admin/");
});
return cb();
};

View file

@ -1,101 +1,116 @@
'use strict';
import type { ArgsExpressType } from "../../types/ArgsExpressType";
import type { ErrorCaused } from "../../types/ErrorCaused";
import type { QueryType } from "../../types/QueryType";
import {ArgsExpressType} from "../../types/ArgsExpressType";
import {ErrorCaused} from "../../types/ErrorCaused";
import {QueryType} from "../../types/QueryType";
import {
getAvailablePlugins,
install,
search,
uninstall,
} from "../../../static/js/pluginfw/installer";
import type { PackageData } from "../../types/PackageInfo";
import {getAvailablePlugins, install, search, uninstall} from "../../../static/js/pluginfw/installer";
import {PackageData} from "../../types/PackageInfo";
const pluginDefs = require("../../../static/js/pluginfw/plugin_defs");
import semver from "semver";
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs');
import semver from 'semver';
exports.socketio = (hookName: string, args: ArgsExpressType, cb: Function) => {
const io = args.io.of("/pluginfw/installer");
io.on("connection", (socket: any) => {
// @ts-ignore
const {
session: {
user: { is_admin: isAdmin } = {},
} = {},
} = socket.conn.request;
if (!isAdmin) return;
socket.on("getInstalled", (query: string) => {
// send currently installed plugins
const installed = Object.keys(pluginDefs.plugins).map(
(plugin) => pluginDefs.plugins[plugin].package,
);
exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => {
const io = args.io.of('/pluginfw/installer');
io.on('connection', (socket:any) => {
// @ts-ignore
const {session: {user: {is_admin: isAdmin} = {}} = {}} = socket.conn.request;
if (!isAdmin) return;
socket.emit("results:installed", { installed });
});
socket.on('getInstalled', (query:string) => {
// send currently installed plugins
const installed =
Object.keys(pluginDefs.plugins).map((plugin) => pluginDefs.plugins[plugin].package);
socket.on("checkUpdates", async () => {
// Check plugins for updates
try {
const results = await getAvailablePlugins(/* maxCacheAge:*/ 60 * 10);
socket.emit('results:installed', {installed});
});
const updatable = Object.keys(pluginDefs.plugins).filter((plugin) => {
if (!results[plugin]) return false;
socket.on('checkUpdates', async () => {
// Check plugins for updates
try {
const results = await getAvailablePlugins(/* maxCacheAge:*/ 60 * 10);
const latestVersion = results[plugin].version;
const currentVersion = pluginDefs.plugins[plugin].package.version;
const updatable = Object.keys(pluginDefs.plugins).filter((plugin) => {
if (!results[plugin]) return false;
return semver.gt(latestVersion, currentVersion);
});
const latestVersion = results[plugin].version;
const currentVersion = pluginDefs.plugins[plugin].package.version;
socket.emit("results:updatable", { updatable });
} catch (err) {
const errc = err as ErrorCaused;
console.warn(errc.stack || errc.toString());
return semver.gt(latestVersion, currentVersion);
});
socket.emit("results:updatable", { updatable: {} });
}
});
socket.emit('results:updatable', {updatable});
} catch (err) {
const errc = err as ErrorCaused
console.warn(errc.stack || errc.toString());
socket.on("getAvailable", async (query: string) => {
try {
const results = await getAvailablePlugins(/* maxCacheAge:*/ false);
socket.emit("results:available", results);
} catch (er) {
console.error(er);
socket.emit("results:available", {});
}
});
socket.emit('results:updatable', {updatable: {}});
}
});
socket.on("search", async (query: QueryType) => {
try {
const results = await search(
query.searchTerm,
/* maxCacheAge:*/ 60 * 10,
);
let res = Object.keys(results)
.map((pluginName) => results[pluginName])
.filter((plugin) => !pluginDefs.plugins[plugin.name]);
res = sortPluginList(res, query.sortBy, query.sortDir).slice(
query.offset,
query.offset + query.limit,
);
socket.emit("results:search", { results: res, query });
} catch (er) {
console.error(er);
socket.on('getAvailable', async (query:string) => {
try {
const results = await getAvailablePlugins(/* maxCacheAge:*/ false);
socket.emit('results:available', results);
} catch (er) {
console.error(er);
socket.emit('results:available', {});
}
});
socket.emit("results:search", { results: {}, query });
}
});
socket.on('search', async (query: QueryType) => {
try {
const results = await search(query.searchTerm, /* maxCacheAge:*/ 60 * 10);
let res = Object.keys(results)
.map((pluginName) => results[pluginName])
.filter((plugin) => !pluginDefs.plugins[plugin.name]);
res = sortPluginList(res, query.sortBy, query.sortDir)
.slice(query.offset, query.offset + query.limit);
socket.emit('results:search', {results: res, query});
} catch (er) {
console.error(er);
socket.on("install", (pluginName: string) => {
install(pluginName, (err: ErrorCaused) => {
if (err) console.warn(err.stack || err.toString());
socket.emit('results:search', {results: {}, query});
}
});
socket.emit("finished:install", {
plugin: pluginName,
code: err ? err.code : null,
error: err ? err.message : null,
});
});
});
socket.on('install', (pluginName: string) => {
install(pluginName, (err: ErrorCaused) => {
if (err) console.warn(err.stack || err.toString());
socket.on("uninstall", (pluginName: string) => {
uninstall(pluginName, (err: ErrorCaused) => {
if (err) console.warn(err.stack || err.toString());
socket.emit('finished:install', {
plugin: pluginName,
code: err ? err.code : null,
error: err ? err.message : null,
});
});
});
socket.on('uninstall', (pluginName:string) => {
uninstall(pluginName, (err:ErrorCaused) => {
if (err) console.warn(err.stack || err.toString());
socket.emit('finished:uninstall', {plugin: pluginName, error: err ? err.message : null});
});
});
});
return cb();
socket.emit("finished:uninstall", {
plugin: pluginName,
error: err ? err.message : null,
});
});
});
});
return cb();
};
/**
@ -105,17 +120,22 @@ exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => {
* @param {String} dir The directory of the plugin
* @return {Object[]}
*/
const sortPluginList = (plugins:PackageData[], property:string, /* ASC?*/dir:string): PackageData[] => plugins.sort((a, b) => {
// @ts-ignore
if (a[property] < b[property]) {
return dir ? -1 : 1;
}
const sortPluginList = (
plugins: PackageData[],
property: string,
/* ASC?*/ dir: string,
): PackageData[] =>
plugins.sort((a, b) => {
// @ts-ignore
if (a[property] < b[property]) {
return dir ? -1 : 1;
}
// @ts-ignore
if (a[property] > b[property]) {
return dir ? 1 : -1;
}
// @ts-ignore
if (a[property] > b[property]) {
return dir ? 1 : -1;
}
// a must be equal to b
return 0;
});
// a must be equal to b
return 0;
});

View file

@ -1,211 +1,226 @@
'use strict';
import {PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery";
import {PadType} from "../../types/PadType";
const eejs = require('../../eejs');
const fsp = require('fs').promises;
const hooks = require('../../../static/js/pluginfw/hooks');
const plugins = require('../../../static/js/pluginfw/plugins');
const settings = require('../../utils/Settings');
const UpdateCheck = require('../../utils/UpdateCheck');
const padManager = require('../../db/PadManager');
const api = require('../../db/API');
import type {
PadQueryResult,
PadSearchQuery,
} from "../../types/PadSearchQuery";
import { PadType } from "../../types/PadType";
const eejs = require("../../eejs");
const fsp = require("fs").promises;
const hooks = require("../../../static/js/pluginfw/hooks");
const plugins = require("../../../static/js/pluginfw/plugins");
const settings = require("../../utils/Settings");
const UpdateCheck = require("../../utils/UpdateCheck");
const padManager = require("../../db/PadManager");
const api = require("../../db/API");
const queryPadLimit = 12;
exports.socketio = (hookName: string, { io }: any) => {
io.of("/settings").on("connection", (socket: any) => {
// @ts-ignore
const {
session: {
user: { is_admin: isAdmin } = {},
} = {},
} = socket.conn.request;
if (!isAdmin) return;
exports.socketio = (hookName:string, {io}:any) => {
io.of('/settings').on('connection', (socket: any ) => {
// @ts-ignore
const {session: {user: {is_admin: isAdmin} = {}} = {}} = socket.conn.request;
if (!isAdmin) return;
socket.on("load", async (query: string): Promise<any> => {
let data;
try {
data = await fsp.readFile(settings.settingsFilename, "utf8");
} catch (err) {
return console.log(err);
}
// if showSettingsInAdminPage is set to false, then return NOT_ALLOWED in the result
if (settings.showSettingsInAdminPage === false) {
socket.emit("settings", { results: "NOT_ALLOWED" });
} else {
socket.emit("settings", { results: data });
}
});
socket.on('load', async (query:string):Promise<any> => {
let data;
try {
data = await fsp.readFile(settings.settingsFilename, 'utf8');
} catch (err) {
return console.log(err);
}
// if showSettingsInAdminPage is set to false, then return NOT_ALLOWED in the result
if (settings.showSettingsInAdminPage === false) {
socket.emit('settings', {results: 'NOT_ALLOWED'});
} else {
socket.emit('settings', {results: data});
}
});
socket.on("saveSettings", async (newSettings: string) => {
console.log(
"Admin request to save settings through a socket on /admin/settings",
);
await fsp.writeFile(settings.settingsFilename, newSettings);
socket.emit("saveprogress", "saved");
});
socket.on('saveSettings', async (newSettings:string) => {
console.log('Admin request to save settings through a socket on /admin/settings');
await fsp.writeFile(settings.settingsFilename, newSettings);
socket.emit('saveprogress', 'saved');
});
socket.on("help", () => {
const gitCommit = settings.getGitCommit();
const epVersion = settings.getEpVersion();
const hooks: Map<string, Map<string, string>> = plugins.getHooks(
"hooks",
false,
);
const clientHooks: Map<string, Map<string, string>> = plugins.getHooks(
"client_hooks",
false,
);
socket.on('help', ()=> {
const gitCommit = settings.getGitCommit();
const epVersion = settings.getEpVersion();
function mapToObject(map: Map<string, any>) {
const obj = Object.create(null);
for (const [k, v] of map) {
if (v instanceof Map) {
obj[k] = mapToObject(v);
} else {
obj[k] = v;
}
}
return obj;
}
const hooks:Map<string, Map<string,string>> = plugins.getHooks('hooks', false);
const clientHooks:Map<string, Map<string,string>> = plugins.getHooks('client_hooks', false);
socket.emit("reply:help", {
gitCommit,
epVersion,
installedPlugins: plugins.getPlugins(),
installedParts: plugins.getParts(),
installedServerHooks: mapToObject(hooks),
installedClientHooks: mapToObject(clientHooks),
latestVersion: UpdateCheck.getLatestVersion(),
});
});
function mapToObject(map: Map<string,any>) {
let obj = Object.create(null);
for (let [k,v] of map) {
if(v instanceof Map) {
obj[k] = mapToObject(v);
} else {
obj[k] = v;
}
}
return obj;
}
socket.on("padLoad", async (query: PadSearchQuery) => {
const { padIDs } = await padManager.listAllPads();
socket.emit('reply:help', {
gitCommit,
epVersion,
installedPlugins: plugins.getPlugins(),
installedParts: plugins.getParts(),
installedServerHooks: mapToObject(hooks),
installedClientHooks: mapToObject(clientHooks),
latestVersion: UpdateCheck.getLatestVersion(),
})
});
const data: {
total: number;
results?: PadQueryResult[];
} = {
total: padIDs.length,
};
let result: string[] = padIDs;
let maxResult;
// Filter out matches
if (query.pattern) {
result = result.filter((padName: string) =>
padName.includes(query.pattern),
);
}
socket.on('padLoad', async (query: PadSearchQuery) => {
const {padIDs} = await padManager.listAllPads();
data.total = result.length;
const data:{
total: number,
results?: PadQueryResult[]
} = {
total: padIDs.length,
};
let result: string[] = padIDs;
let maxResult;
maxResult = result.length - 1;
if (maxResult < 0) {
maxResult = 0;
}
// Filter out matches
if (query.pattern) {
result = result.filter((padName: string) => padName.includes(query.pattern));
}
if (query.offset && query.offset < 0) {
query.offset = 0;
} else if (query.offset > maxResult) {
query.offset = maxResult;
}
data.total = result.length;
if (query.limit && query.limit < 0) {
query.limit = 0;
} else if (query.limit > queryPadLimit) {
query.limit = queryPadLimit;
}
maxResult = result.length - 1;
if (maxResult < 0) {
maxResult = 0;
}
if (query.sortBy === "padName") {
result = result
.sort((a, b) => {
if (a < b) return query.ascending ? -1 : 1;
if (a > b) return query.ascending ? 1 : -1;
return 0;
})
.slice(query.offset, query.offset + query.limit);
if (query.offset && query.offset < 0) {
query.offset = 0;
} else if (query.offset > maxResult) {
query.offset = maxResult;
}
data.results = await Promise.all(
result.map(async (padName: string) => {
const pad = await padManager.getPad(padName);
const revisionNumber = pad.getHeadRevisionNumber();
const userCount = api.padUsersCount(padName).padUsersCount;
const lastEdited = await pad.getLastEdit();
if (query.limit && query.limit < 0) {
query.limit = 0;
} else if (query.limit > queryPadLimit) {
query.limit = queryPadLimit;
}
return {
padName,
lastEdited,
userCount,
revisionNumber,
};
}),
);
} else {
const currentWinners: PadQueryResult[] = [];
let queryOffsetCounter = 0;
for (const res of result) {
const pad = await padManager.getPad(res);
const padType = {
padName: res,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(res).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber(),
};
if (query.sortBy === 'padName') {
result = result.sort((a,b)=>{
if(a < b) return query.ascending ? -1 : 1;
if(a > b) return query.ascending ? 1 : -1;
return 0;
}).slice(query.offset, query.offset + query.limit);
if (currentWinners.length < query.limit) {
if (queryOffsetCounter < query.offset) {
queryOffsetCounter++;
continue;
}
currentWinners.push({
padName: res,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(res).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber(),
});
} else {
// Kick out worst pad and replace by current pad
const worstPad = currentWinners.sort((a, b) => {
if (a[query.sortBy] < b[query.sortBy])
return query.ascending ? -1 : 1;
if (a[query.sortBy] > b[query.sortBy])
return query.ascending ? 1 : -1;
return 0;
});
if (
worstPad[0] &&
worstPad[0][query.sortBy] < padType[query.sortBy]
) {
if (queryOffsetCounter < query.offset) {
queryOffsetCounter++;
continue;
}
currentWinners.splice(currentWinners.indexOf(worstPad[0]), 1);
currentWinners.push({
padName: res,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(res).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber(),
});
}
}
}
data.results = currentWinners;
}
data.results = await Promise.all(result.map(async (padName: string) => {
const pad = await padManager.getPad(padName);
const revisionNumber = pad.getHeadRevisionNumber()
const userCount = api.padUsersCount(padName).padUsersCount;
const lastEdited = await pad.getLastEdit();
socket.emit("results:padLoad", data);
});
return {
padName,
lastEdited,
userCount,
revisionNumber
}}));
} else {
const currentWinners: PadQueryResult[] = []
let queryOffsetCounter = 0
for (let res of result) {
socket.on("deletePad", async (padId: string) => {
const padExists = await padManager.doesPadExists(padId);
if (padExists) {
const pad = await padManager.getPad(padId);
await pad.remove();
socket.emit("results:deletePad", padId);
}
});
const pad = await padManager.getPad(res);
const padType = {
padName: res,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(res).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber()
};
if (currentWinners.length < query.limit) {
if(queryOffsetCounter < query.offset){
queryOffsetCounter++
continue
}
currentWinners.push({
padName: res,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(res).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber()
})
} else {
// Kick out worst pad and replace by current pad
let worstPad = currentWinners.sort((a, b) => {
if (a[query.sortBy] < b[query.sortBy]) return query.ascending ? -1 : 1;
if (a[query.sortBy] > b[query.sortBy]) return query.ascending ? 1 : -1;
return 0;
})
if(worstPad[0]&&worstPad[0][query.sortBy] < padType[query.sortBy]){
if(queryOffsetCounter < query.offset){
queryOffsetCounter++
continue
}
currentWinners.splice(currentWinners.indexOf(worstPad[0]), 1)
currentWinners.push({
padName: res,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(res).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber()
})
}
}
}
data.results = currentWinners;
}
socket.emit('results:padLoad', data);
})
socket.on('deletePad', async (padId: string) => {
const padExists = await padManager.doesPadExists(padId);
if (padExists) {
const pad = await padManager.getPad(padId);
await pad.remove();
socket.emit('results:deletePad', padId);
}
})
socket.on('restartServer', async () => {
console.log('Admin request to restart server through a socket on /admin/settings');
settings.reloadSettings();
await plugins.update();
await hooks.aCallAll('loadSettings', {settings});
await hooks.aCallAll('restartServer');
});
});
socket.on("restartServer", async () => {
console.log(
"Admin request to restart server through a socket on /admin/settings",
);
settings.reloadSettings();
await plugins.update();
await hooks.aCallAll("loadSettings", { settings });
await hooks.aCallAll("restartServer");
});
});
};
const searchPad = async (query:PadSearchQuery) => {
}
const searchPad = async (query: PadSearchQuery) => {};

View file

@ -1,44 +1,45 @@
'use strict';
const log4js = require("log4js");
const clientLogger = log4js.getLogger("client");
const { Formidable } = require("formidable");
const apiHandler = require("../../handler/APIHandler");
const util = require("util");
const log4js = require('log4js');
const clientLogger = log4js.getLogger('client');
const {Formidable} = require('formidable');
const apiHandler = require('../../handler/APIHandler');
const util = require('util');
exports.expressPreSession = async (hookName: string, { app }: any) => {
// The Etherpad client side sends information about how a disconnect happened
app.post("/ep/pad/connection-diagnostic-info", async (req: any, res: any) => {
const [fields, files] = await new Formidable({}).parse(req);
clientLogger.info(`DIAGNOSTIC-INFO: ${fields.diagnosticInfo}`);
res.end("OK");
});
exports.expressPreSession = async (hookName:string, {app}:any) => {
// The Etherpad client side sends information about how a disconnect happened
app.post('/ep/pad/connection-diagnostic-info', async (req:any, res:any) => {
const [fields, files] = await (new Formidable({})).parse(req);
clientLogger.info(`DIAGNOSTIC-INFO: ${fields.diagnosticInfo}`);
res.end('OK');
});
const parseJserrorForm = async (req: any) => {
const form = new Formidable({
maxFileSize: 1, // Files are not expected. Not sure if 0 means unlimited, so 1 is used.
});
const [fields, files] = await form.parse(req);
return fields.errorInfo;
};
const parseJserrorForm = async (req:any) => {
const form = new Formidable({
maxFileSize: 1, // Files are not expected. Not sure if 0 means unlimited, so 1 is used.
});
const [fields, files] = await form.parse(req);
return fields.errorInfo;
};
// The Etherpad client side sends information about client side javscript errors
app.post("/jserror", (req: any, res: any, next: Function) => {
(async () => {
const data = JSON.parse(await parseJserrorForm(req));
clientLogger.warn(`${data.msg} --`, {
[util.inspect.custom]: (depth: number, options: any) => {
// Depth is forced to infinity to ensure that all of the provided data is logged.
options = Object.assign({}, options, {
depth: Number.POSITIVE_INFINITY,
colors: true,
});
return util.inspect(data, options);
},
});
res.end("OK");
})().catch((err) => next(err || new Error(err)));
});
// The Etherpad client side sends information about client side javscript errors
app.post('/jserror', (req:any, res:any, next:Function) => {
(async () => {
const data = JSON.parse(await parseJserrorForm(req));
clientLogger.warn(`${data.msg} --`, {
[util.inspect.custom]: (depth: number, options:any) => {
// Depth is forced to infinity to ensure that all of the provided data is logged.
options = Object.assign({}, options, {depth: Infinity, colors: true});
return util.inspect(data, options);
},
});
res.end('OK');
})().catch((err) => next(err || new Error(err)));
});
// Provide a possibility to query the latest available API version
app.get('/api', (req:any, res:any) => {
res.json({currentVersion: apiHandler.latestApiVersion});
});
// Provide a possibility to query the latest available API version
app.get("/api", (req: any, res: any) => {
res.json({ currentVersion: apiHandler.latestApiVersion });
});
};

View file

@ -1,22 +1,24 @@
'use strict';
import type { ArgsExpressType } from "../../types/ArgsExpressType";
import type { ErrorCaused } from "../../types/ErrorCaused";
import {ArgsExpressType} from "../../types/ArgsExpressType";
import {ErrorCaused} from "../../types/ErrorCaused";
const stats = require("../../stats");
const stats = require('../../stats')
exports.expressCreateServer = (
hook_name: string,
args: ArgsExpressType,
cb: Function,
) => {
exports.app = args.app;
exports.expressCreateServer = (hook_name:string, args: ArgsExpressType, cb:Function) => {
exports.app = args.app;
// Handle errors
args.app.use((err: ErrorCaused, req: any, res: any, next: Function) => {
// if an error occurs Connect will pass it down
// through these "error-handling" middleware
// allowing you to respond however you like
res.status(500).send({ error: "Sorry, something bad happened!" });
console.error(err.stack ? err.stack : err.toString());
stats.meter("http500").mark();
});
// Handle errors
args.app.use((err:ErrorCaused, req:any, res:any, next:Function) => {
// if an error occurs Connect will pass it down
// through these "error-handling" middleware
// allowing you to respond however you like
res.status(500).send({error: 'Sorry, something bad happened!'});
console.error(err.stack ? err.stack : err.toString());
stats.meter('http500').mark();
});
return cb();
return cb();
};

View file

@ -1,89 +1,122 @@
'use strict';
import type { ArgsExpressType } from "../../types/ArgsExpressType";
import {ArgsExpressType} from "../../types/ArgsExpressType";
const hasPadAccess = require("../../padaccess");
const settings = require("../../utils/Settings");
const exportHandler = require("../../handler/ExportHandler");
const importHandler = require("../../handler/ImportHandler");
const padManager = require("../../db/PadManager");
const readOnlyManager = require("../../db/ReadOnlyManager");
const rateLimit = require("express-rate-limit");
const securityManager = require("../../db/SecurityManager");
const webaccess = require("./webaccess");
const hasPadAccess = require('../../padaccess');
const settings = require('../../utils/Settings');
const exportHandler = require('../../handler/ExportHandler');
const importHandler = require('../../handler/ImportHandler');
const padManager = require('../../db/PadManager');
const readOnlyManager = require('../../db/ReadOnlyManager');
const rateLimit = require('express-rate-limit');
const securityManager = require('../../db/SecurityManager');
const webaccess = require('./webaccess');
exports.expressCreateServer = (
hookName: string,
args: ArgsExpressType,
cb: Function,
) => {
const limiter = rateLimit({
...settings.importExportRateLimiting,
handler: (request: any) => {
if (request.rateLimit.current === request.rateLimit.limit + 1) {
// when the rate limiter triggers, write a warning in the logs
console.warn(
"Import/Export rate limiter triggered on " +
`"${request.originalUrl}" for IP address ${request.ip}`,
);
}
},
});
exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {
const limiter = rateLimit({
...settings.importExportRateLimiting,
handler: (request:any) => {
if (request.rateLimit.current === request.rateLimit.limit + 1) {
// when the rate limiter triggers, write a warning in the logs
console.warn('Import/Export rate limiter triggered on ' +
`"${request.originalUrl}" for IP address ${request.ip}`);
}
},
});
// handle export requests
args.app.use("/p/:pad/:rev?/export/:type", limiter);
args.app.get(
"/p/:pad/:rev?/export/:type",
(req: any, res: any, next: Function) => {
(async () => {
const types = ["pdf", "doc", "txt", "html", "odt", "etherpad"];
// send a 404 if we don't support this filetype
if (types.indexOf(req.params.type) === -1) {
return next();
}
// handle export requests
args.app.use('/p/:pad/:rev?/export/:type', limiter);
args.app.get('/p/:pad/:rev?/export/:type', (req:any, res:any, next:Function) => {
(async () => {
const types = ['pdf', 'doc', 'txt', 'html', 'odt', 'etherpad'];
// send a 404 if we don't support this filetype
if (types.indexOf(req.params.type) === -1) {
return next();
}
// if abiword is disabled, and this is a format we only support with abiword, output a message
if (
settings.exportAvailable() === "no" &&
["odt", "pdf", "doc"].indexOf(req.params.type) !== -1
) {
console.error(
`Impossible to export pad "${req.params.pad}" in ${req.params.type} format.` +
" There is no converter configured",
);
// if abiword is disabled, and this is a format we only support with abiword, output a message
if (settings.exportAvailable() === 'no' &&
['odt', 'pdf', 'doc'].indexOf(req.params.type) !== -1) {
console.error(`Impossible to export pad "${req.params.pad}" in ${req.params.type} format.` +
' There is no converter configured');
// ACHTUNG: do not include req.params.type in res.send() because there is
// no HTML escaping and it would lead to an XSS
res.send(
"This export is not enabled at this Etherpad instance. Set the path to Abiword" +
" or soffice (LibreOffice) in settings.json to enable this feature",
);
return;
}
// ACHTUNG: do not include req.params.type in res.send() because there is
// no HTML escaping and it would lead to an XSS
res.send('This export is not enabled at this Etherpad instance. Set the path to Abiword' +
' or soffice (LibreOffice) in settings.json to enable this feature');
return;
}
res.header("Access-Control-Allow-Origin", "*");
res.header('Access-Control-Allow-Origin', '*');
if (await hasPadAccess(req, res)) {
let padId = req.params.pad;
if (await hasPadAccess(req, res)) {
let padId = req.params.pad;
let readOnlyId = null;
if (readOnlyManager.isReadOnlyId(padId)) {
readOnlyId = padId;
padId = await readOnlyManager.getPadId(readOnlyId);
}
let readOnlyId = null;
if (readOnlyManager.isReadOnlyId(padId)) {
readOnlyId = padId;
padId = await readOnlyManager.getPadId(readOnlyId);
}
const exists = await padManager.doesPadExists(padId);
if (!exists) {
console.warn(
`Someone tried to export a pad that doesn't exist (${padId})`,
);
return next();
}
const exists = await padManager.doesPadExists(padId);
if (!exists) {
console.warn(`Someone tried to export a pad that doesn't exist (${padId})`);
return next();
}
console.log(
`Exporting pad "${req.params.pad}" in ${req.params.type} format`,
);
await exportHandler.doExport(
req,
res,
padId,
readOnlyId,
req.params.type,
);
}
})().catch((err) => next(err || new Error(err)));
},
);
console.log(`Exporting pad "${req.params.pad}" in ${req.params.type} format`);
await exportHandler.doExport(req, res, padId, readOnlyId, req.params.type);
}
})().catch((err) => next(err || new Error(err)));
});
// handle import requests
args.app.use("/p/:pad/import", limiter);
args.app.post("/p/:pad/import", (req: any, res: any, next: Function) => {
(async () => {
// @ts-ignore
const {
session: { user } = {},
} = req;
const { accessStatus, authorID: authorId } =
await securityManager.checkAccess(
req.params.pad,
req.cookies.sessionID,
req.cookies.token,
user,
);
if (
accessStatus !== "grant" ||
!webaccess.userCanModify(req.params.pad, req)
) {
return res.status(403).send("Forbidden");
}
await importHandler.doImport(req, res, req.params.pad, authorId);
})().catch((err) => next(err || new Error(err)));
});
// handle import requests
args.app.use('/p/:pad/import', limiter);
args.app.post('/p/:pad/import', (req:any, res:any, next:Function) => {
(async () => {
// @ts-ignore
const {session: {user} = {}} = req;
const {accessStatus, authorID: authorId} = await securityManager.checkAccess(
req.params.pad, req.cookies.sessionID, req.cookies.token, user);
if (accessStatus !== 'grant' || !webaccess.userCanModify(req.params.pad, req)) {
return res.status(403).send('Forbidden');
}
await importHandler.doImport(req, res, req.params.pad, authorId);
})().catch((err) => next(err || new Error(err)));
});
return cb();
return cb();
};

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,39 @@
'use strict';
import type { ArgsExpressType } from "../../types/ArgsExpressType";
import {ArgsExpressType} from "../../types/ArgsExpressType";
const padManager = require("../../db/PadManager");
const padManager = require('../../db/PadManager');
exports.expressCreateServer = (
hookName: string,
args: ArgsExpressType,
cb: Function,
) => {
// redirects browser to the pad's sanitized url if needed. otherwise, renders the html
args.app.param("pad", (req: any, res: any, next: Function, padId: string) => {
(async () => {
// ensure the padname is valid and the url doesn't end with a /
if (!padManager.isValidPadId(padId) || /\/$/.test(req.url)) {
res.status(404).send("Such a padname is forbidden");
return;
}
exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {
// redirects browser to the pad's sanitized url if needed. otherwise, renders the html
args.app.param('pad', (req:any, res:any, next:Function, padId:string) => {
(async () => {
// ensure the padname is valid and the url doesn't end with a /
if (!padManager.isValidPadId(padId) || /\/$/.test(req.url)) {
res.status(404).send('Such a padname is forbidden');
return;
}
const sanitizedPadId = await padManager.sanitizePadId(padId);
const sanitizedPadId = await padManager.sanitizePadId(padId);
if (sanitizedPadId === padId) {
// the pad id was fine, so just render it
next();
} else {
// the pad id was sanitized, so we redirect to the sanitized version
const realURL =
encodeURIComponent(sanitizedPadId) + new URL(req.url, 'http://invalid.invalid').search;
res.header('Location', realURL);
res.status(302).send(`You should be redirected to <a href="${realURL}">${realURL}</a>`);
}
})().catch((err) => next(err || new Error(err)));
});
return cb();
if (sanitizedPadId === padId) {
// the pad id was fine, so just render it
next();
} else {
// the pad id was sanitized, so we redirect to the sanitized version
const realURL =
encodeURIComponent(sanitizedPadId) +
new URL(req.url, "http://invalid.invalid").search;
res.header("Location", realURL);
res
.status(302)
.send(
`You should be redirected to <a href="${realURL}">${realURL}</a>`,
);
}
})().catch((err) => next(err || new Error(err)));
});
return cb();
};

View file

@ -1,142 +1,146 @@
'use strict';
import type { ArgsExpressType } from "../../types/ArgsExpressType";
import {ArgsExpressType} from "../../types/ArgsExpressType";
import events from "events";
const express = require("../express");
import log4js from "log4js";
const proxyaddr = require("proxy-addr");
const settings = require("../../utils/Settings");
import { Server, type Socket } from "socket.io";
const socketIORouter = require("../../handler/SocketIORouter");
const hooks = require("../../../static/js/pluginfw/hooks");
const padMessageHandler = require("../../handler/PadMessageHandler");
import events from 'events';
const express = require('../express');
import log4js from 'log4js';
const proxyaddr = require('proxy-addr');
const settings = require('../../utils/Settings');
import {Server, Socket} from 'socket.io'
const socketIORouter = require('../../handler/SocketIORouter');
const hooks = require('../../../static/js/pluginfw/hooks');
const padMessageHandler = require('../../handler/PadMessageHandler');
let io:any;
const logger = log4js.getLogger('socket.io');
let io: any;
const logger = log4js.getLogger("socket.io");
const sockets = new Set();
const socketsEvents = new events.EventEmitter();
export const expressCloseServer = async () => {
if (io == null) return;
logger.info('Closing socket.io engine...');
// Close the socket.io engine to disconnect existing clients and reject new clients. Don't call
// io.close() because that closes the underlying HTTP server, which is already done elsewhere.
// (Closing an HTTP server twice throws an exception.) The `engine` property of socket.io Server
// objects is undocumented, but I don't see any other way to shut down socket.io without also
// closing the HTTP server.
io.engine.close();
// Closing the socket.io engine should disconnect all clients but it is not documented. Wait for
// all of the connections to close to make sure, and log the progress so that we can troubleshoot
// if socket.io's behavior ever changes.
//
// Note: `io.sockets.clients()` should not be used here to track the remaining clients.
// `io.sockets.clients()` works with socket.io 2.x, but not with 3.x: With socket.io 2.x all
// clients are always added to the default namespace (`io.sockets`) even if they specified a
// different namespace upon connection, but with socket.io 3.x clients are NOT added to the
// default namespace if they have specified a different namespace. With socket.io 3.x there does
// not appear to be a way to get all clients across all namespaces without tracking them
// ourselves, so that is what we do.
let lastLogged = 0;
while (sockets.size > 0 && !settings.enableAdminUITests) {
if (Date.now() - lastLogged > 1000) { // Rate limit to avoid filling logs.
logger.info(`Waiting for ${sockets.size} socket.io clients to disconnect...`);
lastLogged = Date.now();
}
await events.once(socketsEvents, 'updated');
}
logger.info('All socket.io clients have disconnected');
if (io == null) return;
logger.info("Closing socket.io engine...");
// Close the socket.io engine to disconnect existing clients and reject new clients. Don't call
// io.close() because that closes the underlying HTTP server, which is already done elsewhere.
// (Closing an HTTP server twice throws an exception.) The `engine` property of socket.io Server
// objects is undocumented, but I don't see any other way to shut down socket.io without also
// closing the HTTP server.
io.engine.close();
// Closing the socket.io engine should disconnect all clients but it is not documented. Wait for
// all of the connections to close to make sure, and log the progress so that we can troubleshoot
// if socket.io's behavior ever changes.
//
// Note: `io.sockets.clients()` should not be used here to track the remaining clients.
// `io.sockets.clients()` works with socket.io 2.x, but not with 3.x: With socket.io 2.x all
// clients are always added to the default namespace (`io.sockets`) even if they specified a
// different namespace upon connection, but with socket.io 3.x clients are NOT added to the
// default namespace if they have specified a different namespace. With socket.io 3.x there does
// not appear to be a way to get all clients across all namespaces without tracking them
// ourselves, so that is what we do.
let lastLogged = 0;
while (sockets.size > 0 && !settings.enableAdminUITests) {
if (Date.now() - lastLogged > 1000) {
// Rate limit to avoid filling logs.
logger.info(
`Waiting for ${sockets.size} socket.io clients to disconnect...`,
);
lastLogged = Date.now();
}
await events.once(socketsEvents, "updated");
}
logger.info("All socket.io clients have disconnected");
};
const socketSessionMiddleware = (args: any) => (socket: any, next: Function) => {
const req = socket.request;
// Express sets req.ip but socket.io does not. Replicate Express's behavior here.
if (req.ip == null) {
if (settings.trustProxy) {
req.ip = proxyaddr(req, args.app.get('trust proxy fn'));
} else {
req.ip = socket.handshake.address;
}
}
if (!req.headers.cookie) {
// socketio.js-client on node.js doesn't support cookies, so pass them via a query parameter.
req.headers.cookie = socket.handshake.query.cookie;
}
express.sessionMiddleware(req, {}, next);
};
export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {
// init socket.io and redirect all requests to the MessageHandler
// there shouldn't be a browser that isn't compatible to all
// transports in this list at once
// e.g. XHR is disabled in IE by default, so in IE it should use jsonp-polling
io = new Server(args.server,{
transports: settings.socketTransportProtocols,
cookie: false,
maxHttpBufferSize: settings.socketIo.maxHttpBufferSize,
})
const handleConnection = (socket:Socket) => {
sockets.add(socket);
socketsEvents.emit('updated');
// https://socket.io/docs/v3/faq/index.html
// @ts-ignore
const session = socket.request.session;
session.connections++;
session.save();
socket.on('disconnect', () => {
sockets.delete(socket);
socketsEvents.emit('updated');
});
}
const renewSession = (socket:any, next:Function) => {
socket.conn.on('packet', (packet:string) => {
// Tell express-session that the session is still active. The session store can use these
// touch events to defer automatic session cleanup, and if express-session is configured with
// rolling=true the cookie's expiration time will be renewed. (Note that WebSockets does not
// have a standard mechanism for periodically updating the browser's cookies, so the browser
// will not see the new cookie expiration time unless it makes a new HTTP request or the new
// cookie value is sent to the client in a custom socket.io message.)
if (socket.request.session != null) socket.request.session.touch();
});
next();
}
io.on('connection', handleConnection);
io.use(socketSessionMiddleware(args));
// Temporary workaround so all clients go through middleware and handle connection
io.of('/pluginfw/installer')
.on('connection',handleConnection)
.use(socketSessionMiddleware(args))
.use(renewSession)
io.of('/settings')
.on('connection',handleConnection)
.use(socketSessionMiddleware(args))
.use(renewSession)
io.use(renewSession);
// var socketIOLogger = log4js.getLogger("socket.io");
// Debug logging now has to be set at an environment level, this is stupid.
// https://github.com/Automattic/socket.io/wiki/Migrating-to-1.0
// This debug logging environment is set in Settings.js
// minify socket.io javascript
// Due to a shitty decision by the SocketIO team minification is
// no longer available, details available at:
// http://stackoverflow.com/questions/23981741/minify-socket-io-socket-io-js-with-1-0
// if(settings.minify) io.enable('browser client minification');
// Initialize the Socket.IO Router
socketIORouter.setSocketIO(io);
socketIORouter.addComponent('pad', padMessageHandler);
hooks.callAll('socketio', {app: args.app, io, server: args.server});
return cb();
const socketSessionMiddleware =
(args: any) => (socket: any, next: Function) => {
const req = socket.request;
// Express sets req.ip but socket.io does not. Replicate Express's behavior here.
if (req.ip == null) {
if (settings.trustProxy) {
req.ip = proxyaddr(req, args.app.get("trust proxy fn"));
} else {
req.ip = socket.handshake.address;
}
}
if (!req.headers.cookie) {
// socketio.js-client on node.js doesn't support cookies, so pass them via a query parameter.
req.headers.cookie = socket.handshake.query.cookie;
}
express.sessionMiddleware(req, {}, next);
};
export const expressCreateServer = (
hookName: string,
args: ArgsExpressType,
cb: Function,
) => {
// init socket.io and redirect all requests to the MessageHandler
// there shouldn't be a browser that isn't compatible to all
// transports in this list at once
// e.g. XHR is disabled in IE by default, so in IE it should use jsonp-polling
io = new Server(args.server, {
transports: settings.socketTransportProtocols,
cookie: false,
maxHttpBufferSize: settings.socketIo.maxHttpBufferSize,
});
const handleConnection = (socket: Socket) => {
sockets.add(socket);
socketsEvents.emit("updated");
// https://socket.io/docs/v3/faq/index.html
// @ts-ignore
const session = socket.request.session;
session.connections++;
session.save();
socket.on("disconnect", () => {
sockets.delete(socket);
socketsEvents.emit("updated");
});
};
const renewSession = (socket: any, next: Function) => {
socket.conn.on("packet", (packet: string) => {
// Tell express-session that the session is still active. The session store can use these
// touch events to defer automatic session cleanup, and if express-session is configured with
// rolling=true the cookie's expiration time will be renewed. (Note that WebSockets does not
// have a standard mechanism for periodically updating the browser's cookies, so the browser
// will not see the new cookie expiration time unless it makes a new HTTP request or the new
// cookie value is sent to the client in a custom socket.io message.)
if (socket.request.session != null) socket.request.session.touch();
});
next();
};
io.on("connection", handleConnection);
io.use(socketSessionMiddleware(args));
// Temporary workaround so all clients go through middleware and handle connection
io.of("/pluginfw/installer")
.on("connection", handleConnection)
.use(socketSessionMiddleware(args))
.use(renewSession);
io.of("/settings")
.on("connection", handleConnection)
.use(socketSessionMiddleware(args))
.use(renewSession);
io.use(renewSession);
// var socketIOLogger = log4js.getLogger("socket.io");
// Debug logging now has to be set at an environment level, this is stupid.
// https://github.com/Automattic/socket.io/wiki/Migrating-to-1.0
// This debug logging environment is set in Settings.js
// minify socket.io javascript
// Due to a shitty decision by the SocketIO team minification is
// no longer available, details available at:
// http://stackoverflow.com/questions/23981741/minify-socket-io-socket-io-js-with-1-0
// if(settings.minify) io.enable('browser client minification');
// Initialize the Socket.IO Router
socketIORouter.setSocketIO(io);
socketIORouter.addComponent("pad", padMessageHandler);
hooks.callAll("socketio", { app: args.app, io, server: args.server });
return cb();
};

View file

@ -1,121 +1,139 @@
'use strict';
const path = require('path');
const eejs = require('../../eejs');
const fs = require('fs');
const path = require("path");
const eejs = require("../../eejs");
const fs = require("fs");
const fsp = fs.promises;
const toolbar = require('../../utils/toolbar');
const hooks = require('../../../static/js/pluginfw/hooks');
const settings = require('../../utils/Settings');
const util = require('util');
const webaccess = require('./webaccess');
const toolbar = require("../../utils/toolbar");
const hooks = require("../../../static/js/pluginfw/hooks");
const settings = require("../../utils/Settings");
const util = require("util");
const webaccess = require("./webaccess");
exports.expressPreSession = async (hookName:string, {app}:any) => {
// This endpoint is intended to conform to:
// https://www.ietf.org/archive/id/draft-inadarei-api-health-check-06.html
app.get('/health', (req:any, res:any) => {
res.set('Content-Type', 'application/health+json');
res.json({
status: 'pass',
releaseId: settings.getEpVersion(),
});
});
exports.expressPreSession = async (hookName: string, { app }: any) => {
// This endpoint is intended to conform to:
// https://www.ietf.org/archive/id/draft-inadarei-api-health-check-06.html
app.get("/health", (req: any, res: any) => {
res.set("Content-Type", "application/health+json");
res.json({
status: "pass",
releaseId: settings.getEpVersion(),
});
});
app.get('/stats', (req:any, res:any) => {
res.json(require('../../stats').toJSON());
});
app.get("/stats", (req: any, res: any) => {
res.json(require("../../stats").toJSON());
});
app.get('/javascript', (req:any, res:any) => {
res.send(eejs.require('ep_etherpad-lite/templates/javascript.html', {req}));
});
app.get("/javascript", (req: any, res: any) => {
res.send(
eejs.require("ep_etherpad-lite/templates/javascript.html", { req }),
);
});
app.get('/robots.txt', (req:any, res:any) => {
let filePath =
path.join(settings.root, 'src', 'static', 'skins', settings.skinName, 'robots.txt');
res.sendFile(filePath, (err:any) => {
// there is no custom robots.txt, send the default robots.txt which dissallows all
if (err) {
filePath = path.join(settings.root, 'src', 'static', 'robots.txt');
res.sendFile(filePath);
}
});
});
app.get("/robots.txt", (req: any, res: any) => {
let filePath = path.join(
settings.root,
"src",
"static",
"skins",
settings.skinName,
"robots.txt",
);
res.sendFile(filePath, (err: any) => {
// there is no custom robots.txt, send the default robots.txt which dissallows all
if (err) {
filePath = path.join(settings.root, "src", "static", "robots.txt");
res.sendFile(filePath);
}
});
});
app.get('/favicon.ico', (req:any, res:any, next:Function) => {
(async () => {
/*
app.get("/favicon.ico", (req: any, res: any, next: Function) => {
(async () => {
/*
If this is a url we simply redirect to that one.
*/
if (settings.favicon && settings.favicon.startsWith('http')) {
res.redirect(settings.favicon);
res.send();
return;
}
if (settings.favicon && settings.favicon.startsWith("http")) {
res.redirect(settings.favicon);
res.send();
return;
}
const fns = [
...(settings.favicon ? [path.resolve(settings.root, settings.favicon)] : []),
path.join(settings.root, 'src', 'static', 'skins', settings.skinName, 'favicon.ico'),
path.join(settings.root, 'src', 'static', 'favicon.ico'),
];
for (const fn of fns) {
try {
await fsp.access(fn, fs.constants.R_OK);
} catch (err) {
continue;
}
res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);
await util.promisify(res.sendFile.bind(res))(fn);
return;
}
next();
})().catch((err) => next(err || new Error(err)));
});
const fns = [
...(settings.favicon
? [path.resolve(settings.root, settings.favicon)]
: []),
path.join(
settings.root,
"src",
"static",
"skins",
settings.skinName,
"favicon.ico",
),
path.join(settings.root, "src", "static", "favicon.ico"),
];
for (const fn of fns) {
try {
await fsp.access(fn, fs.constants.R_OK);
} catch (err) {
continue;
}
res.setHeader("Cache-Control", `public, max-age=${settings.maxAge}`);
await util.promisify(res.sendFile.bind(res))(fn);
return;
}
next();
})().catch((err) => next(err || new Error(err)));
});
};
exports.expressCreateServer = (hookName:string, args:any, cb:Function) => {
// serve index.html under /
args.app.get('/', (req:any, res:any) => {
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req}));
});
exports.expressCreateServer = (hookName: string, args: any, cb: Function) => {
// serve index.html under /
args.app.get("/", (req: any, res: any) => {
res.send(eejs.require("ep_etherpad-lite/templates/index.html", { req }));
});
// serve pad.html under /p
args.app.get('/p/:pad', (req:any, res:any, next:Function) => {
// The below might break for pads being rewritten
const isReadOnly = !webaccess.userCanModify(req.params.pad, req);
// serve pad.html under /p
args.app.get("/p/:pad", (req: any, res: any, next: Function) => {
// The below might break for pads being rewritten
const isReadOnly = !webaccess.userCanModify(req.params.pad, req);
hooks.callAll('padInitToolbar', {
toolbar,
isReadOnly,
});
hooks.callAll("padInitToolbar", {
toolbar,
isReadOnly,
});
// can be removed when require-kernel is dropped
res.header('Feature-Policy', 'sync-xhr \'self\'');
res.send(eejs.require('ep_etherpad-lite/templates/pad.html', {
req,
toolbar,
isReadOnly,
}));
});
// can be removed when require-kernel is dropped
res.header("Feature-Policy", "sync-xhr 'self'");
res.send(
eejs.require("ep_etherpad-lite/templates/pad.html", {
req,
toolbar,
isReadOnly,
}),
);
});
// serve timeslider.html under /p/$padname/timeslider
args.app.get('/p/:pad/timeslider', (req:any, res:any, next:Function) => {
hooks.callAll('padInitToolbar', {
toolbar,
});
// serve timeslider.html under /p/$padname/timeslider
args.app.get("/p/:pad/timeslider", (req: any, res: any, next: Function) => {
hooks.callAll("padInitToolbar", {
toolbar,
});
res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', {
req,
toolbar,
}));
});
res.send(
eejs.require("ep_etherpad-lite/templates/timeslider.html", {
req,
toolbar,
}),
);
});
// The client occasionally polls this endpoint to get an updated expiration for the express_sid
// cookie. This handler must be installed after the express-session middleware.
args.app.put('/_extendExpressSessionLifetime', (req:any, res:any) => {
// express-session automatically calls req.session.touch() so we don't need to do it here.
res.json({status: 'ok'});
});
// The client occasionally polls this endpoint to get an updated expiration for the express_sid
// cookie. This handler must be installed after the express-session middleware.
args.app.put("/_extendExpressSessionLifetime", (req: any, res: any) => {
// express-session automatically calls req.session.touch() so we don't need to do it here.
res.json({ status: "ok" });
});
return cb();
return cb();
};

View file

@ -1,81 +1,94 @@
'use strict';
import type { MapArrayType } from "../../types/MapType";
import type { PartType } from "../../types/PartType";
import {MapArrayType} from "../../types/MapType";
import {PartType} from "../../types/PartType";
const fs = require('fs').promises;
const minify = require('../../utils/Minify');
const path = require('path');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const settings = require('../../utils/Settings');
import CachingMiddleware from '../../utils/caching_middleware';
const Yajsml = require('etherpad-yajsml');
const fs = require("fs").promises;
const minify = require("../../utils/Minify");
const path = require("path");
const plugins = require("../../../static/js/pluginfw/plugin_defs");
const settings = require("../../utils/Settings");
import CachingMiddleware from "../../utils/caching_middleware";
const Yajsml = require("etherpad-yajsml");
// Rewrite tar to include modules with no extensions and proper rooted paths.
const getTar = async () => {
const prefixLocalLibraryPath = (path:string) => {
if (path.charAt(0) === '$') {
return path.slice(1);
} else {
return `ep_etherpad-lite/static/js/${path}`;
}
};
const prefixLocalLibraryPath = (path: string) => {
if (path.charAt(0) === "$") {
return path.slice(1);
} else {
return `ep_etherpad-lite/static/js/${path}`;
}
};
const tarJson = await fs.readFile(path.join(settings.root, 'src/node/utils/tar.json'), 'utf8');
const tar:MapArrayType<string[]> = {};
for (const [key, relativeFiles] of Object.entries(JSON.parse(tarJson)) as [string, string[]][]) {
const files = relativeFiles.map(prefixLocalLibraryPath);
tar[prefixLocalLibraryPath(key)] = files
.concat(files.map((p) => p.replace(/\.js$/, '')))
.concat(files.map((p) => `${p.replace(/\.js$/, '')}/index.js`));
}
return tar;
const tarJson = await fs.readFile(
path.join(settings.root, "src/node/utils/tar.json"),
"utf8",
);
const tar: MapArrayType<string[]> = {};
for (const [key, relativeFiles] of Object.entries(JSON.parse(tarJson)) as [
string,
string[],
][]) {
const files = relativeFiles.map(prefixLocalLibraryPath);
tar[prefixLocalLibraryPath(key)] = files
.concat(files.map((p) => p.replace(/\.js$/, "")))
.concat(files.map((p) => `${p.replace(/\.js$/, "")}/index.js`));
}
return tar;
};
exports.expressPreSession = async (hookName:string, {app}:any) => {
// Cache both minified and static.
const assetCache = new CachingMiddleware();
// Cache static assets
app.all(/\/js\/(.*)/, assetCache.handle.bind(assetCache));
app.all(/\/css\/(.*)/, assetCache.handle.bind(assetCache));
exports.expressPreSession = async (hookName: string, { app }: any) => {
// Cache both minified and static.
const assetCache = new CachingMiddleware();
// Cache static assets
app.all(/\/js\/(.*)/, assetCache.handle.bind(assetCache));
app.all(/\/css\/(.*)/, assetCache.handle.bind(assetCache));
// Minify will serve static files compressed (minify enabled). It also has
// file-specific hacks for ace/require-kernel/etc.
app.all('/static/:filename(*)', minify.minify);
// Minify will serve static files compressed (minify enabled). It also has
// file-specific hacks for ace/require-kernel/etc.
app.all("/static/:filename(*)", minify.minify);
// Setup middleware that will package JavaScript files served by minify for
// CommonJS loader on the client-side.
// Hostname "invalid.invalid" is a dummy value to allow parsing as a URI.
const jsServer = new (Yajsml.Server)({
rootPath: 'javascripts/src/',
rootURI: 'http://invalid.invalid/static/js/',
libraryPath: 'javascripts/lib/',
libraryURI: 'http://invalid.invalid/static/plugins/',
requestURIs: minify.requestURIs, // Loop-back is causing problems, this is a workaround.
});
// Setup middleware that will package JavaScript files served by minify for
// CommonJS loader on the client-side.
// Hostname "invalid.invalid" is a dummy value to allow parsing as a URI.
const jsServer = new Yajsml.Server({
rootPath: "javascripts/src/",
rootURI: "http://invalid.invalid/static/js/",
libraryPath: "javascripts/lib/",
libraryURI: "http://invalid.invalid/static/plugins/",
requestURIs: minify.requestURIs, // Loop-back is causing problems, this is a workaround.
});
const StaticAssociator = Yajsml.associators.StaticAssociator;
const associations = Yajsml.associators.associationsForSimpleMapping(await getTar());
const associator = new StaticAssociator(associations);
jsServer.setAssociator(associator);
const StaticAssociator = Yajsml.associators.StaticAssociator;
const associations = Yajsml.associators.associationsForSimpleMapping(
await getTar(),
);
const associator = new StaticAssociator(associations);
jsServer.setAssociator(associator);
app.use(jsServer.handle.bind(jsServer));
app.use(jsServer.handle.bind(jsServer));
// serve plugin definitions
// not very static, but served here so that client can do
// require("pluginfw/static/js/plugin-definitions.js");
app.get('/pluginfw/plugin-definitions.json', (req: any, res:any, next:Function) => {
const clientParts = plugins.parts.filter((part: PartType) => part.client_hooks != null);
const clientPlugins:MapArrayType<string> = {};
for (const name of new Set(clientParts.map((part: PartType) => part.plugin))) {
// @ts-ignore
clientPlugins[name] = {...plugins.plugins[name]};
// @ts-ignore
delete clientPlugins[name].package;
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);
res.write(JSON.stringify({plugins: clientPlugins, parts: clientParts}));
res.end();
});
// serve plugin definitions
// not very static, but served here so that client can do
// require("pluginfw/static/js/plugin-definitions.js");
app.get(
"/pluginfw/plugin-definitions.json",
(req: any, res: any, next: Function) => {
const clientParts = plugins.parts.filter(
(part: PartType) => part.client_hooks != null,
);
const clientPlugins: MapArrayType<string> = {};
for (const name of new Set(
clientParts.map((part: PartType) => part.plugin),
)) {
// @ts-ignore
clientPlugins[name] = { ...plugins.plugins[name] };
// @ts-ignore
delete clientPlugins[name].package;
}
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("Cache-Control", `public, max-age=${settings.maxAge}`);
res.write(JSON.stringify({ plugins: clientPlugins, parts: clientParts }));
res.end();
},
);
};

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