Added biomejs as formatter and linter

This commit is contained in:
SamTV12345 2024-04-17 21:29:15 +02:00
parent 1d3e899249
commit c64c4a4073
339 changed files with 78646 additions and 66730 deletions

View file

@ -278,6 +278,9 @@ importers:
specifier: ^0.9.2 specifier: ^0.9.2
version: 0.9.2 version: 0.9.2
devDependencies: devDependencies:
'@biomejs/biome':
specifier: 1.7.0
version: 1.7.0
'@playwright/test': '@playwright/test':
specifier: ^1.43.1 specifier: ^1.43.1
version: 1.43.1 version: 1.43.1
@ -721,6 +724,94 @@ packages:
to-fast-properties: 2.0.0 to-fast-properties: 2.0.0
dev: true 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: /@docsearch/css@3.6.0:
resolution: {integrity: sha512-+sbxb71sWre+PwDK7X2T8+bhS6clcVMLwBPznX45Qu6opJcgRjAp7gYSDzVFp187J+feSj5dNBN1mJoi6ckkUQ==} resolution: {integrity: sha512-+sbxb71sWre+PwDK7X2T8+bhS6clcVMLwBPznX45Qu6opJcgRjAp7gYSDzVFp187J+feSj5dNBN1mJoi6ckkUQ==}
dev: true dev: true

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,6 @@
{ {
"@metadata": { "@metadata": {
"authors": [ "authors": ["Admresdeserv.", "Jmg.cmdi", "Oskars", "Papuass", "Silraks"]
"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.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:", "index.createOpenPad": "vai izveidojiet/atveriet Blociņu ar nosaukumu:",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
'use strict'; "use strict";
/** /**
* Handles the import requests * Handles the import requests
*/ */
@ -21,53 +21,53 @@
* limitations under the License. * limitations under the License.
*/ */
const padManager = require('../db/PadManager'); const padManager = require("../db/PadManager");
const padMessageHandler = require('./PadMessageHandler'); const padMessageHandler = require("./PadMessageHandler");
import {promises as fs} from 'fs'; import { promises as fs } from "fs";
import path from 'path'; import path from "path";
const settings = require('../utils/Settings'); const settings = require("../utils/Settings");
const {Formidable} = require('formidable'); const { Formidable } = require("formidable");
import os from 'os'; import os from "os";
const importHtml = require('../utils/ImportHtml'); const importHtml = require("../utils/ImportHtml");
const importEtherpad = require('../utils/ImportEtherpad'); const importEtherpad = require("../utils/ImportEtherpad");
import log4js from 'log4js'; import log4js from "log4js";
const hooks = require('../../static/js/pluginfw/hooks.js'); 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`. // `status` must be a string supported by `importErrorMessage()` in `src/static/js/pad_impexp.js`.
class ImportError extends Error { class ImportError extends Error {
status: string; status: string;
constructor(status: string, ...args:any) { constructor(status: string, ...args: any) {
super(...args); super(...args);
if (Error.captureStackTrace) Error.captureStackTrace(this, ImportError); if (Error.captureStackTrace) Error.captureStackTrace(this, ImportError);
this.name = 'ImportError'; this.name = "ImportError";
this.status = status; this.status = status;
const msg = this.message == null ? '' : String(this.message); const msg = this.message == null ? "" : String(this.message);
if (status !== '') this.message = msg === '' ? status : `${status}: ${msg}`; if (status !== "") this.message = msg === "" ? status : `${status}: ${msg}`;
} }
} }
const rm = async (path: string) => { const rm = async (path: string) => {
try { try {
await fs.unlink(path); await fs.unlink(path);
} catch (err:any) { } catch (err: any) {
if (err.code !== 'ENOENT') throw err; if (err.code !== "ENOENT") throw err;
} }
}; };
let converter:any = null; let converter: any = null;
let exportExtension = 'htm'; let exportExtension = "htm";
// load abiword only if it is enabled and if soffice is disabled // load abiword only if it is enabled and if soffice is disabled
if (settings.abiword != null && settings.soffice == null) { if (settings.abiword != null && settings.soffice == null) {
converter = require('../utils/Abiword'); converter = require("../utils/Abiword");
} }
// load soffice only if it is enabled // load soffice only if it is enabled
if (settings.soffice != null) { if (settings.soffice != null) {
converter = require('../utils/LibreOffice'); converter = require("../utils/LibreOffice");
exportExtension = 'html'; exportExtension = "html";
} }
const tmpDirectory = os.tmpdir(); const tmpDirectory = os.tmpdir();
@ -79,163 +79,193 @@ const tmpDirectory = os.tmpdir();
* @param {String} padId the pad id to export * @param {String} padId the pad id to export
* @param {String} authorId the author id to use for the import * @param {String} authorId the author id to use for the import
*/ */
const doImport = async (req:any, res:any, padId:string, authorId:string) => { const doImport = async (
// pipe to a file req: any,
// convert file to html via abiword or soffice res: any,
// set html in the pad padId: string,
const randNum = Math.floor(Math.random() * 0xFFFFFFFF); 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 // setting flag for whether to use converter or not
let useConverter = (converter != null); let useConverter = converter != null;
const form = new Formidable({ const form = new Formidable({
keepExtensions: true, keepExtensions: true,
uploadDir: tmpDirectory, uploadDir: tmpDirectory,
maxFileSize: settings.importMaxFileSize, maxFileSize: settings.importMaxFileSize,
}); });
let srcFile; let srcFile;
let files; let files;
let fields; let fields;
try { try {
[fields, files] = await form.parse(req); [fields, files] = await form.parse(req);
} catch (err:any) { } catch (err: any) {
logger.warn(`Import failed due to form error: ${err.stack || err}`); logger.warn(`Import failed due to form error: ${err.stack || err}`);
if (err.code === Formidable.formidableErrors.biggerThanMaxFileSize) { if (err.code === Formidable.formidableErrors.biggerThanMaxFileSize) {
throw new ImportError('maxFileSize'); throw new ImportError("maxFileSize");
} }
throw new ImportError('uploadFailed'); throw new ImportError("uploadFailed");
} }
if (!files.file) { if (!files.file) {
logger.warn('Import failed because form had no file'); logger.warn("Import failed because form had no file");
throw new ImportError('uploadFailed'); throw new ImportError("uploadFailed");
} else { } else {
srcFile = files.file[0].filepath; srcFile = files.file[0].filepath;
} }
// ensure this is a file ending we know, else we change the file ending to .txt // 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 // this allows us to accept source code files like .c or .java
const fileEnding = path.extname(files.file[0].originalFilename).toLowerCase(); const fileEnding = path.extname(files.file[0].originalFilename).toLowerCase();
const knownFileEndings = const knownFileEndings = [
['.txt', '.doc', '.docx', '.pdf', '.odt', '.html', '.htm', '.etherpad', '.rtf']; ".txt",
const fileEndingUnknown = (knownFileEndings.indexOf(fileEnding) < 0); ".doc",
".docx",
".pdf",
".odt",
".html",
".htm",
".etherpad",
".rtf",
];
const fileEndingUnknown = knownFileEndings.indexOf(fileEnding) < 0;
if (fileEndingUnknown) { if (fileEndingUnknown) {
// the file ending is not known // the file ending is not known
if (settings.allowUnknownFileEnds === true) { if (settings.allowUnknownFileEnds === true) {
// we need to rename this file with a .txt ending // we need to rename this file with a .txt ending
const oldSrcFile = srcFile; const oldSrcFile = srcFile;
srcFile = path.join(path.dirname(srcFile), `${path.basename(srcFile, fileEnding)}.txt`); srcFile = path.join(
await fs.rename(oldSrcFile, srcFile); path.dirname(srcFile),
} else { `${path.basename(srcFile, fileEnding)}.txt`,
logger.warn(`Not allowing unknown file type to be imported: ${fileEnding}`); );
throw new ImportError('uploadFailed'); 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 destFile = path.join(
const context = {srcFile, destFile, fileEnding, padId, ImportError}; tmpDirectory,
const importHandledByPlugin = (await hooks.aCallAll('import', context)).some((x:string) => x); `etherpad_import_${randNum}.${exportExtension}`,
const fileIsEtherpad = (fileEnding === '.etherpad'); );
const fileIsHTML = (fileEnding === '.html' || fileEnding === '.htm'); const context = { srcFile, destFile, fileEnding, padId, ImportError };
const fileIsTXT = (fileEnding === '.txt'); 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; let directDatabaseAccess = false;
if (fileIsEtherpad) { if (fileIsEtherpad) {
// Use '\n' to avoid the default pad text if the pad doesn't yet exist. // Use '\n' to avoid the default pad text if the pad doesn't yet exist.
const pad = await padManager.getPad(padId, '\n', authorId); const pad = await padManager.getPad(padId, "\n", authorId);
const headCount = pad.head; const headCount = pad.head;
if (headCount >= 10) { if (headCount >= 10) {
logger.warn('Aborting direct database import attempt of a pad that already has content'); logger.warn(
throw new ImportError('padHasData'); "Aborting direct database import attempt of a pad that already has content",
} );
const text = await fs.readFile(srcFile, 'utf8'); throw new ImportError("padHasData");
directDatabaseAccess = true; }
await importEtherpad.setPadRaw(padId, text, authorId); const text = await fs.readFile(srcFile, "utf8");
} directDatabaseAccess = true;
await importEtherpad.setPadRaw(padId, text, authorId);
}
// convert file to html if necessary // convert file to html if necessary
if (!importHandledByPlugin && !directDatabaseAccess) { if (!importHandledByPlugin && !directDatabaseAccess) {
if (fileIsTXT) { if (fileIsTXT) {
// Don't use converter for text files // Don't use converter for text files
useConverter = false; useConverter = false;
} }
// See https://github.com/ether/etherpad-lite/issues/2572 // See https://github.com/ether/etherpad-lite/issues/2572
if (fileIsHTML || !useConverter) { if (fileIsHTML || !useConverter) {
// if no converter only rename // if no converter only rename
await fs.rename(srcFile, destFile); await fs.rename(srcFile, destFile);
} else { } else {
try { try {
await converter.convertFile(srcFile, destFile, exportExtension); await converter.convertFile(srcFile, destFile, exportExtension);
} catch (err:any) { } catch (err: any) {
logger.warn(`Converting Error: ${err.stack || err}`); logger.warn(`Converting Error: ${err.stack || err}`);
throw new ImportError('convertFailed'); throw new ImportError("convertFailed");
} }
} }
} }
if (!useConverter && !directDatabaseAccess) { if (!useConverter && !directDatabaseAccess) {
// Read the file with no encoding for raw buffer access. // Read the file with no encoding for raw buffer access.
const buf = await fs.readFile(destFile); const buf = await fs.readFile(destFile);
// Check if there are only ascii chars in the uploaded file // Check if there are only ascii chars in the uploaded file
const isAscii = !Array.prototype.some.call(buf, (c) => (c > 240)); const isAscii = !Array.prototype.some.call(buf, (c) => c > 240);
if (!isAscii) { if (!isAscii) {
logger.warn('Attempt to import non-ASCII file'); logger.warn("Attempt to import non-ASCII file");
throw new ImportError('uploadFailed'); throw new ImportError("uploadFailed");
} }
} }
// Use '\n' to avoid the default pad text if the pad doesn't yet exist. // Use '\n' to avoid the default pad text if the pad doesn't yet exist.
let pad = await padManager.getPad(padId, '\n', authorId); let pad = await padManager.getPad(padId, "\n", authorId);
// read the text // read the text
let text; let text;
if (!directDatabaseAccess) { if (!directDatabaseAccess) {
text = await fs.readFile(destFile, 'utf8'); text = await fs.readFile(destFile, "utf8");
// node on windows has a delay on releasing of the file lock. // node on windows has a delay on releasing of the file lock.
// We add a 100ms delay to work around this // We add a 100ms delay to work around this
if (os.type().indexOf('Windows') > -1) { if (os.type().indexOf("Windows") > -1) {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
} }
} }
// change text of the pad and broadcast the changeset // change text of the pad and broadcast the changeset
if (!directDatabaseAccess) { if (!directDatabaseAccess) {
if (importHandledByPlugin || useConverter || fileIsHTML) { if (importHandledByPlugin || useConverter || fileIsHTML) {
try { try {
await importHtml.setPadHTML(pad, text, authorId); await importHtml.setPadHTML(pad, text, authorId);
} catch (err:any) { } catch (err: any) {
logger.warn(`Error importing, possibly caused by malformed HTML: ${err.stack || err}`); logger.warn(
} `Error importing, possibly caused by malformed HTML: ${
} else { err.stack || err
await pad.setText(text, authorId); }`,
} );
} }
} else {
await pad.setText(text, authorId);
}
}
// Load the Pad into memory then broadcast updates to all clients // Load the Pad into memory then broadcast updates to all clients
padManager.unloadPad(padId); padManager.unloadPad(padId);
pad = await padManager.getPad(padId, '\n', authorId); pad = await padManager.getPad(padId, "\n", authorId);
padManager.unloadPad(padId); padManager.unloadPad(padId);
// Direct database access means a pad user should reload the pad and not attempt to receive // Direct database access means a pad user should reload the pad and not attempt to receive
// updated pad data. // updated pad data.
if (directDatabaseAccess) return true; if (directDatabaseAccess) return true;
// tell clients to update // tell clients to update
await padMessageHandler.updatePadClients(pad); await padMessageHandler.updatePadClients(pad);
// clean up temporary files // clean up temporary files
rm(srcFile); rm(srcFile);
rm(destFile); rm(destFile);
return false; return false;
}; };
/** /**
@ -246,19 +276,27 @@ const doImport = async (req:any, res:any, padId:string, authorId:string) => {
* @param {String} authorId the author id to use for the import * @param {String} authorId the author id to use for the import
* @return {Promise<void>} a promise * @return {Promise<void>} a promise
*/ */
exports.doImport = async (req:any, res:any, padId:string, authorId:string = '') => { exports.doImport = async (
let httpStatus = 200; req: any,
let code = 0; res: any,
let message = 'ok'; padId: string,
let directDatabaseAccess; authorId: string = "",
try { ) => {
directDatabaseAccess = await doImport(req, res, padId, authorId); let httpStatus = 200;
} catch (err:any) { let code = 0;
const known = err instanceof ImportError && err.status; let message = "ok";
if (!known) logger.error(`Internal error during import: ${err.stack || err}`); let directDatabaseAccess;
httpStatus = known ? 400 : 500; try {
code = known ? 1 : 2; directDatabaseAccess = await doImport(req, res, padId, authorId);
message = known ? err.status : 'internalError'; } catch (err: any) {
} const known = err instanceof ImportError && err.status;
res.status(httpStatus).json({code, message, data: {directDatabaseAccess}}); 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,4 @@
'use strict'; "use strict";
/** /**
* This is the Socket.IO Router. It routes the Messages between the * 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 * components of the Server. The components are at the moment: pad and timeslider
@ -20,87 +20,98 @@
* limitations under the License. * limitations under the License.
*/ */
import {MapArrayType} from "../types/MapType"; import { MapArrayType } from "../types/MapType";
import {SocketModule} from "../types/SocketModule"; import { SocketModule } from "../types/SocketModule";
const log4js = require('log4js'); const log4js = require("log4js");
const settings = require('../utils/Settings'); const settings = require("../utils/Settings");
const stats = require('../../node/stats') const stats = require("../../node/stats");
const logger = log4js.getLogger('socket.io'); const logger = log4js.getLogger("socket.io");
/** /**
* Saves all components * Saves all components
* key is the component name * key is the component name
* value is the component module * value is the component module
*/ */
const components:MapArrayType<any> = {}; const components: MapArrayType<any> = {};
let io:any; let io: any;
/** adds a component /** adds a component
* @param {string} moduleName * @param {string} moduleName
* @param {Module} module * @param {Module} module
*/ */
exports.addComponent = (moduleName: string, module: SocketModule) => { exports.addComponent = (moduleName: string, module: SocketModule) => {
if (module == null) return exports.deleteComponent(moduleName); if (module == null) return exports.deleteComponent(moduleName);
components[moduleName] = module; components[moduleName] = module;
module.setSocketIO(io); module.setSocketIO(io);
}; };
/** /**
* removes a component * removes a component
* @param {Module} moduleName * @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 * sets the socket.io and adds event functions for routing
* @param {Object} _io the socket.io instance * @param {Object} _io the socket.io instance
*/ */
exports.setSocketIO = (_io:any) => { exports.setSocketIO = (_io: any) => {
io = _io; io = _io;
io.sockets.on('connection', (socket:any) => { io.sockets.on("connection", (socket: any) => {
const ip = settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip; const ip = settings.disableIPlogging ? "ANONYMOUS" : socket.request.ip;
logger.debug(`${socket.id} connected from IP ${ip}`); logger.debug(`${socket.id} connected from IP ${ip}`);
// wrap the original send function to log the messages // wrap the original send function to log the messages
socket._send = socket.send; socket._send = socket.send;
socket.send = (message: string) => { socket.send = (message: string) => {
logger.debug(`to ${socket.id}: ${JSON.stringify(message)}`); logger.debug(`to ${socket.id}: ${JSON.stringify(message)}`);
socket._send(message); socket._send(message);
}; };
// tell all components about this connect // tell all components about this connect
for (const i of Object.keys(components)) { for (const i of Object.keys(components)) {
components[i].handleConnect(socket); components[i].handleConnect(socket);
} }
socket.on('message', (message: any, ack: any = () => {}) => (async () => { socket.on("message", (message: any, ack: any = () => {}) =>
if (!message.component || !components[message.component]) { (async () => {
throw new Error(`unknown message component: ${message.component}`); 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); logger.debug(`from ${socket.id}:`, message);
})().then( return await components[message.component].handleMessage(
(val) => ack(null, val), socket,
(err) => { message,
logger.error( );
`Error handling ${message.component} message from ${socket.id}: ${err.stack || err}`); })().then(
ack({name: err.name, message: err.message}); // socket.io can't handle Error objects. (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) => { socket.on("disconnect", (reason: string) => {
logger.debug(`${socket.id} disconnected: ${reason}`); logger.debug(`${socket.id} disconnected: ${reason}`);
// store the lastDisconnect as a timestamp, this is useful if you want to know // 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 // 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 // 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. // this instance can be brought out of a scaling cluster.
stats.gauge('lastDisconnect', () => Date.now()); stats.gauge("lastDisconnect", () => Date.now());
// tell all components about this disconnect // tell all components about this disconnect
for (const i of Object.keys(components)) { for (const i of Object.keys(components)) {
components[i].handleDisconnect(socket); components[i].handleDisconnect(socket);
} }
}); });
}); });
}; };

View file

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

View file

@ -1,11 +1,11 @@
'use strict'; "use strict";
import {ArgsExpressType} from "../../types/ArgsExpressType"; import { ArgsExpressType } from "../../types/ArgsExpressType";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import express from "express"; import express from "express";
const settings = require('ep_etherpad-lite/node/utils/Settings'); const settings = require("ep_etherpad-lite/node/utils/Settings");
const ADMIN_PATH = path.join(settings.root, 'src', 'templates', 'admin'); const ADMIN_PATH = path.join(settings.root, "src", "templates", "admin");
/** /**
* Add the admin navigation link * Add the admin navigation link
@ -14,13 +14,24 @@ const ADMIN_PATH = path.join(settings.root, 'src', 'templates', 'admin');
* @param {Function} cb the callback function * @param {Function} cb the callback function
* @return {*} * @return {*}
*/ */
exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function): any => { exports.expressCreateServer = (
args.app.use('/admin/', express.static(path.join(__dirname, '../../../templates/admin'), {maxAge: 1000 * 60 * 60 * 24})); hookName: string,
args.app.get('/admin/*', (_request:any, response:any)=>{ args: ArgsExpressType,
response.sendFile(path.resolve(__dirname,'../../../templates/admin', 'index.html')); cb: Function,
} ) ): any => {
args.app.get('/admin', (req:any, res:any, next:Function) => { args.app.use(
if ('/' !== req.path[req.path.length - 1]) return res.redirect('./admin/'); "/admin/",
}) express.static(path.join(__dirname, "../../../templates/admin"), {
return cb(); 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,118 @@
'use strict'; "use strict";
import {ArgsExpressType} from "../../types/ArgsExpressType"; import { ArgsExpressType } from "../../types/ArgsExpressType";
import {ErrorCaused} from "../../types/ErrorCaused"; import { ErrorCaused } from "../../types/ErrorCaused";
import {QueryType} from "../../types/QueryType"; import { QueryType } from "../../types/QueryType";
import {getAvailablePlugins, install, search, uninstall} from "../../../static/js/pluginfw/installer"; import {
import {PackageData} from "../../types/PackageInfo"; getAvailablePlugins,
install,
search,
uninstall,
} from "../../../static/js/pluginfw/installer";
import { PackageData } from "../../types/PackageInfo";
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs'); const pluginDefs = require("../../../static/js/pluginfw/plugin_defs");
import semver from 'semver'; 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;
exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => { socket.on("getInstalled", (query: string) => {
const io = args.io.of('/pluginfw/installer'); // send currently installed plugins
io.on('connection', (socket:any) => { const installed = Object.keys(pluginDefs.plugins).map(
// @ts-ignore (plugin) => pluginDefs.plugins[plugin].package,
const {session: {user: {is_admin: isAdmin} = {}} = {}} = socket.conn.request; );
if (!isAdmin) return;
socket.on('getInstalled', (query:string) => { socket.emit("results:installed", { installed });
// send currently installed plugins });
const installed =
Object.keys(pluginDefs.plugins).map((plugin) => pluginDefs.plugins[plugin].package);
socket.emit('results:installed', {installed}); socket.on("checkUpdates", async () => {
}); // Check plugins for updates
try {
const results = await getAvailablePlugins(/* maxCacheAge:*/ 60 * 10);
socket.on('checkUpdates', async () => { const updatable = Object.keys(pluginDefs.plugins).filter((plugin) => {
// Check plugins for updates if (!results[plugin]) return false;
try {
const results = await getAvailablePlugins(/* maxCacheAge:*/ 60 * 10);
const updatable = Object.keys(pluginDefs.plugins).filter((plugin) => { const latestVersion = results[plugin].version;
if (!results[plugin]) return false; const currentVersion = pluginDefs.plugins[plugin].package.version;
const latestVersion = results[plugin].version; return semver.gt(latestVersion, currentVersion);
const currentVersion = pluginDefs.plugins[plugin].package.version; });
return semver.gt(latestVersion, currentVersion); socket.emit("results:updatable", { updatable });
}); } catch (err) {
const errc = err as ErrorCaused;
console.warn(errc.stack || errc.toString());
socket.emit('results:updatable', {updatable}); socket.emit("results:updatable", { updatable: {} });
} catch (err) { }
const errc = err as ErrorCaused });
console.warn(errc.stack || errc.toString());
socket.emit('results:updatable', {updatable: {}}); 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.on('getAvailable', async (query:string) => { socket.on("search", async (query: QueryType) => {
try { try {
const results = await getAvailablePlugins(/* maxCacheAge:*/ false); const results = await search(
socket.emit('results:available', results); query.searchTerm,
} catch (er) { /* maxCacheAge:*/ 60 * 10,
console.error(er); );
socket.emit('results:available', {}); 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('search', async (query: QueryType) => { socket.emit("results:search", { results: {}, query });
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.emit('results:search', {results: {}, query}); socket.on("install", (pluginName: string) => {
} install(pluginName, (err: ErrorCaused) => {
}); if (err) console.warn(err.stack || err.toString());
socket.on('install', (pluginName: string) => { socket.emit("finished:install", {
install(pluginName, (err: ErrorCaused) => { plugin: pluginName,
if (err) console.warn(err.stack || err.toString()); code: err ? err.code : null,
error: err ? err.message : null,
});
});
});
socket.emit('finished:install', { socket.on("uninstall", (pluginName: string) => {
plugin: pluginName, uninstall(pluginName, (err: ErrorCaused) => {
code: err ? err.code : null, if (err) console.warn(err.stack || err.toString());
error: err ? err.message : null,
});
});
});
socket.on('uninstall', (pluginName:string) => { socket.emit("finished:uninstall", {
uninstall(pluginName, (err:ErrorCaused) => { plugin: pluginName,
if (err) console.warn(err.stack || err.toString()); error: err ? err.message : null,
});
socket.emit('finished:uninstall', {plugin: pluginName, error: err ? err.message : null}); });
}); });
}); });
}); return cb();
return cb();
}; };
/** /**
@ -105,17 +122,22 @@ exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => {
* @param {String} dir The directory of the plugin * @param {String} dir The directory of the plugin
* @return {Object[]} * @return {Object[]}
*/ */
const sortPluginList = (plugins:PackageData[], property:string, /* ASC?*/dir:string): PackageData[] => plugins.sort((a, b) => { const sortPluginList = (
// @ts-ignore plugins: PackageData[],
if (a[property] < b[property]) { property: string,
return dir ? -1 : 1; /* ASC?*/ dir: string,
} ): PackageData[] =>
plugins.sort((a, b) => {
// @ts-ignore
if (a[property] < b[property]) {
return dir ? -1 : 1;
}
// @ts-ignore // @ts-ignore
if (a[property] > b[property]) { if (a[property] > b[property]) {
return dir ? 1 : -1; return dir ? 1 : -1;
} }
// a must be equal to b // a must be equal to b
return 0; return 0;
}); });

View file

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

View file

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

View file

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

View file

@ -1,89 +1,124 @@
'use strict'; "use strict";
import {ArgsExpressType} from "../../types/ArgsExpressType"; import { ArgsExpressType } from "../../types/ArgsExpressType";
const hasPadAccess = require('../../padaccess'); const hasPadAccess = require("../../padaccess");
const settings = require('../../utils/Settings'); const settings = require("../../utils/Settings");
const exportHandler = require('../../handler/ExportHandler'); const exportHandler = require("../../handler/ExportHandler");
const importHandler = require('../../handler/ImportHandler'); const importHandler = require("../../handler/ImportHandler");
const padManager = require('../../db/PadManager'); const padManager = require("../../db/PadManager");
const readOnlyManager = require('../../db/ReadOnlyManager'); const readOnlyManager = require("../../db/ReadOnlyManager");
const rateLimit = require('express-rate-limit'); const rateLimit = require("express-rate-limit");
const securityManager = require('../../db/SecurityManager'); const securityManager = require("../../db/SecurityManager");
const webaccess = require('./webaccess'); const webaccess = require("./webaccess");
exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => { exports.expressCreateServer = (
const limiter = rateLimit({ hookName: string,
...settings.importExportRateLimiting, args: ArgsExpressType,
handler: (request:any) => { cb: Function,
if (request.rateLimit.current === request.rateLimit.limit + 1) { ) => {
// when the rate limiter triggers, write a warning in the logs const limiter = rateLimit({
console.warn('Import/Export rate limiter triggered on ' + ...settings.importExportRateLimiting,
`"${request.originalUrl}" for IP address ${request.ip}`); 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 // handle export requests
args.app.use('/p/:pad/:rev?/export/:type', limiter); args.app.use("/p/:pad/:rev?/export/:type", limiter);
args.app.get('/p/:pad/:rev?/export/:type', (req:any, res:any, next:Function) => { args.app.get(
(async () => { "/p/:pad/:rev?/export/:type",
const types = ['pdf', 'doc', 'txt', 'html', 'odt', 'etherpad']; (req: any, res: any, next: Function) => {
// send a 404 if we don't support this filetype (async () => {
if (types.indexOf(req.params.type) === -1) { const types = ["pdf", "doc", "txt", "html", "odt", "etherpad"];
return next(); // 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 abiword is disabled, and this is a format we only support with abiword, output a message
if (settings.exportAvailable() === 'no' && if (
['odt', 'pdf', 'doc'].indexOf(req.params.type) !== -1) { settings.exportAvailable() === "no" &&
console.error(`Impossible to export pad "${req.params.pad}" in ${req.params.type} format.` + ["odt", "pdf", "doc"].indexOf(req.params.type) !== -1
' There is no converter configured'); ) {
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 // ACHTUNG: do not include req.params.type in res.send() because there is
// no HTML escaping and it would lead to an XSS // 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' + res.send(
' or soffice (LibreOffice) in settings.json to enable this feature'); "This export is not enabled at this Etherpad instance. Set the path to Abiword" +
return; " 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)) { if (await hasPadAccess(req, res)) {
let padId = req.params.pad; let padId = req.params.pad;
let readOnlyId = null; let readOnlyId = null;
if (readOnlyManager.isReadOnlyId(padId)) { if (readOnlyManager.isReadOnlyId(padId)) {
readOnlyId = padId; readOnlyId = padId;
padId = await readOnlyManager.getPadId(readOnlyId); padId = await readOnlyManager.getPadId(readOnlyId);
} }
const exists = await padManager.doesPadExists(padId); const exists = await padManager.doesPadExists(padId);
if (!exists) { if (!exists) {
console.warn(`Someone tried to export a pad that doesn't exist (${padId})`); console.warn(
return next(); `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`); console.log(
await exportHandler.doExport(req, res, padId, readOnlyId, req.params.type); `Exporting pad "${req.params.pad}" in ${req.params.type} format`,
} );
})().catch((err) => next(err || new Error(err))); await exportHandler.doExport(
}); req,
res,
padId,
readOnlyId,
req.params.type,
);
}
})().catch((err) => next(err || new Error(err)));
},
);
// handle import requests // handle import requests
args.app.use('/p/:pad/import', limiter); args.app.use("/p/:pad/import", limiter);
args.app.post('/p/:pad/import', (req:any, res:any, next:Function) => { args.app.post("/p/:pad/import", (req: any, res: any, next: Function) => {
(async () => { (async () => {
// @ts-ignore // @ts-ignore
const {session: {user} = {}} = req; const {
const {accessStatus, authorID: authorId} = await securityManager.checkAccess( session: { user } = {},
req.params.pad, req.cookies.sessionID, req.cookies.token, user); } = req;
if (accessStatus !== 'grant' || !webaccess.userCanModify(req.params.pad, req)) { const { accessStatus, authorID: authorId } =
return res.status(403).send('Forbidden'); await securityManager.checkAccess(
} req.params.pad,
await importHandler.doImport(req, res, req.params.pad, authorId); req.cookies.sessionID,
})().catch((err) => next(err || new Error(err))); 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,41 @@
'use strict'; "use strict";
import {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) => { exports.expressCreateServer = (
// redirects browser to the pad's sanitized url if needed. otherwise, renders the html hookName: string,
args.app.param('pad', (req:any, res:any, next:Function, padId:string) => { args: ArgsExpressType,
(async () => { cb: Function,
// ensure the padname is valid and the url doesn't end with a / ) => {
if (!padManager.isValidPadId(padId) || /\/$/.test(req.url)) { // redirects browser to the pad's sanitized url if needed. otherwise, renders the html
res.status(404).send('Such a padname is forbidden'); args.app.param("pad", (req: any, res: any, next: Function, padId: string) => {
return; (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) { if (sanitizedPadId === padId) {
// the pad id was fine, so just render it // the pad id was fine, so just render it
next(); next();
} else { } else {
// the pad id was sanitized, so we redirect to the sanitized version // the pad id was sanitized, so we redirect to the sanitized version
const realURL = const realURL =
encodeURIComponent(sanitizedPadId) + new URL(req.url, 'http://invalid.invalid').search; encodeURIComponent(sanitizedPadId) +
res.header('Location', realURL); new URL(req.url, "http://invalid.invalid").search;
res.status(302).send(`You should be redirected to <a href="${realURL}">${realURL}</a>`); res.header("Location", realURL);
} res
})().catch((err) => next(err || new Error(err))); .status(302)
}); .send(
return cb(); `You should be redirected to <a href="${realURL}">${realURL}</a>`,
);
}
})().catch((err) => next(err || new Error(err)));
});
return cb();
}; };

View file

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

View file

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

View file

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

View file

@ -1,83 +1,103 @@
'use strict'; "use strict";
import {Dirent} from "node:fs"; import { Dirent } from "node:fs";
import {PluginDef} from "../../types/PartType"; import { PluginDef } from "../../types/PartType";
const path = require('path'); const path = require("path");
const fsp = require('fs').promises; const fsp = require("fs").promises;
const plugins = require('../../../static/js/pluginfw/plugin_defs'); const plugins = require("../../../static/js/pluginfw/plugin_defs");
const sanitizePathname = require('../../utils/sanitizePathname'); const sanitizePathname = require("../../utils/sanitizePathname");
const settings = require('../../utils/Settings'); const settings = require("../../utils/Settings");
// Returns all *.js files under specDir (recursively) as relative paths to specDir, using '/' // Returns all *.js files under specDir (recursively) as relative paths to specDir, using '/'
// instead of path.sep to separate pathname components. // instead of path.sep to separate pathname components.
const findSpecs = async (specDir: string) => { const findSpecs = async (specDir: string) => {
let dirents: Dirent[]; let dirents: Dirent[];
try { try {
dirents = await fsp.readdir(specDir, {withFileTypes: true}); dirents = await fsp.readdir(specDir, { withFileTypes: true });
} catch (err:any) { } catch (err: any) {
if (['ENOENT', 'ENOTDIR'].includes(err.code)) return []; if (["ENOENT", "ENOTDIR"].includes(err.code)) return [];
throw err; throw err;
} }
const specs: string[] = []; const specs: string[] = [];
await Promise.all(dirents.map(async (dirent) => { await Promise.all(
if (dirent.isDirectory()) { dirents.map(async (dirent) => {
const subdirSpecs = await findSpecs(path.join(specDir, dirent.name)); if (dirent.isDirectory()) {
specs.push(...subdirSpecs.map((spec) => `${dirent.name}/${spec}`)); const subdirSpecs = await findSpecs(path.join(specDir, dirent.name));
return; specs.push(...subdirSpecs.map((spec) => `${dirent.name}/${spec}`));
} return;
if (!dirent.name.endsWith('.js')) return; }
specs.push(dirent.name); if (!dirent.name.endsWith(".js")) return;
})); specs.push(dirent.name);
return specs; }),
);
return specs;
}; };
exports.expressPreSession = async (hookName:string, {app}:any) => { exports.expressPreSession = async (hookName: string, { app }: any) => {
app.get('/tests/frontend/frontendTestSpecs.json', (req:any, res:any, next:Function) => { app.get(
(async () => { "/tests/frontend/frontendTestSpecs.json",
const modules:string[] = []; (req: any, res: any, next: Function) => {
await Promise.all(Object.entries(plugins.plugins).map(async ([plugin, def]) => { (async () => {
let {package: {path: pluginPath}} = def as PluginDef; const modules: string[] = [];
if (!pluginPath.endsWith(path.sep)) pluginPath += path.sep; await Promise.all(
const specDir = `${plugin === 'ep_etherpad-lite' ? '' : 'static/'}tests/frontend/specs`; Object.entries(plugins.plugins).map(async ([plugin, def]) => {
for (const spec of await findSpecs(path.join(pluginPath, specDir))) { let {
if (plugin === 'ep_etherpad-lite' && !settings.enableAdminUITests && package: { path: pluginPath },
spec.startsWith('admin')) continue; } = def as PluginDef;
modules.push(`${plugin}/${specDir}/${spec.replace(/\.js$/, '')}`); if (!pluginPath.endsWith(path.sep)) pluginPath += path.sep;
} const specDir = `${
})); plugin === "ep_etherpad-lite" ? "" : "static/"
// Sort plugin tests before core tests. }tests/frontend/specs`;
modules.sort((a, b) => { for (const spec of await findSpecs(
a = String(a); path.join(pluginPath, specDir),
b = String(b); )) {
const aCore = a.startsWith('ep_etherpad-lite/'); if (
const bCore = b.startsWith('ep_etherpad-lite/'); plugin === "ep_etherpad-lite" &&
if (aCore === bCore) return a.localeCompare(b); !settings.enableAdminUITests &&
return aCore ? 1 : -1; spec.startsWith("admin")
}); )
console.debug('Sent browser the following test spec modules:', modules); continue;
res.json(modules); modules.push(`${plugin}/${specDir}/${spec.replace(/\.js$/, "")}`);
})().catch((err) => next(err || new Error(err))); }
}); }),
);
// Sort plugin tests before core tests.
modules.sort((a, b) => {
a = String(a);
b = String(b);
const aCore = a.startsWith("ep_etherpad-lite/");
const bCore = b.startsWith("ep_etherpad-lite/");
if (aCore === bCore) return a.localeCompare(b);
return aCore ? 1 : -1;
});
console.debug("Sent browser the following test spec modules:", modules);
res.json(modules);
})().catch((err) => next(err || new Error(err)));
},
);
const rootTestFolder = path.join(settings.root, 'src/tests/frontend/'); const rootTestFolder = path.join(settings.root, "src/tests/frontend/");
app.get('/tests/frontend/index.html', (req:any, res:any) => { app.get("/tests/frontend/index.html", (req: any, res: any) => {
res.redirect(['./', ...req.url.split('?').slice(1)].join('?')); res.redirect(["./", ...req.url.split("?").slice(1)].join("?"));
}); });
// The regexp /[\d\D]{0,}/ is equivalent to the regexp /.*/. The Express route path used here // The regexp /[\d\D]{0,}/ is equivalent to the regexp /.*/. The Express route path used here
// uses the more verbose /[\d\D]{0,}/ pattern instead of /.*/ because path-to-regexp v0.1.7 (the // uses the more verbose /[\d\D]{0,}/ pattern instead of /.*/ because path-to-regexp v0.1.7 (the
// version used with Express v4.x) interprets '.' and '*' differently than regexp. // version used with Express v4.x) interprets '.' and '*' differently than regexp.
app.get('/tests/frontend/:file([\\d\\D]{0,})', (req:any, res:any, next:Function) => { app.get(
(async () => { "/tests/frontend/:file([\\d\\D]{0,})",
let file = sanitizePathname(req.params.file); (req: any, res: any, next: Function) => {
if (['', '.', './'].includes(file)) file = 'index.html'; (async () => {
res.sendFile(path.join(rootTestFolder, file)); let file = sanitizePathname(req.params.file);
})().catch((err) => next(err || new Error(err))); if (["", ".", "./"].includes(file)) file = "index.html";
}); res.sendFile(path.join(rootTestFolder, file));
})().catch((err) => next(err || new Error(err)));
},
);
app.get('/tests/frontend', (req:any, res:any) => { app.get("/tests/frontend", (req: any, res: any) => {
res.redirect(['./frontend/', ...req.url.split('?').slice(1)].join('?')); res.redirect(["./frontend/", ...req.url.split("?").slice(1)].join("?"));
}); });
}; };

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