Fixed formatting.

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

View file

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

View file

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

12
src/biome.json Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -150,8 +150,8 @@
"timeslider.saved": "Saved {{month}} {{day}}, {{year}}",
"timeslider.playPause": "Playback / Pause Pad Contents",
"timeslider.backRevision":"Go back a revision in this Pad",
"timeslider.forwardRevision":"Go forward a revision in this Pad",
"timeslider.backRevision": "Go back a revision in this Pad",
"timeslider.forwardRevision": "Go forward a revision in this Pad",
"timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}",
"timeslider.month.january": "January",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
'use strict';
/**
* This module provides all API functions
*/
@ -19,21 +18,21 @@
* limitations under the License.
*/
const Changeset = require('../../static/js/Changeset');
const ChatMessage = require('../../static/js/ChatMessage');
const CustomError = require('../utils/customError');
const padManager = require('./PadManager');
const padMessageHandler = require('../handler/PadMessageHandler');
const readOnlyManager = require('./ReadOnlyManager');
const groupManager = require('./GroupManager');
const authorManager = require('./AuthorManager');
const sessionManager = require('./SessionManager');
const exportHtml = require('../utils/ExportHtml');
const exportTxt = require('../utils/ExportTxt');
const importHtml = require('../utils/ImportHtml');
const cleanText = require('./Pad').cleanText;
const PadDiff = require('../utils/padDiff');
const {checkValidRev, isInt} = require('../utils/checkValidRev');
const Changeset = require("../../static/js/Changeset");
const ChatMessage = require("../../static/js/ChatMessage");
const CustomError = require("../utils/customError");
const padManager = require("./PadManager");
const padMessageHandler = require("../handler/PadMessageHandler");
const readOnlyManager = require("./ReadOnlyManager");
const groupManager = require("./GroupManager");
const authorManager = require("./AuthorManager");
const sessionManager = require("./SessionManager");
const exportHtml = require("../utils/ExportHtml");
const exportTxt = require("../utils/ExportTxt");
const importHtml = require("../utils/ImportHtml");
const cleanText = require("./Pad").cleanText;
const PadDiff = require("../utils/padDiff");
const { checkValidRev, isInt } = require("../utils/checkValidRev");
/* ********************
* GROUP FUNCTIONS ****
@ -106,7 +105,7 @@ Example returns:
*/
exports.getAttributePool = async (padID: string) => {
const pad = await getPadSafe(padID, true);
return {pool: pad.pool};
return { pool: pad.pool };
};
/**
@ -136,7 +135,10 @@ exports.getRevisionChangeset = async (padID: string, rev: string) => {
if (rev !== undefined) {
// check if this is a valid revision
if (rev > head) {
throw new CustomError('rev is higher than the head revision of the pad', 'apierror');
throw new CustomError(
"rev is higher than the head revision of the pad",
"apierror",
);
}
// get the changeset for this revision
@ -169,19 +171,22 @@ exports.getText = async (padID: string, rev: string) => {
if (rev !== undefined) {
// check if this is a valid revision
if (rev > head) {
throw new CustomError('rev is higher than the head revision of the pad', 'apierror');
throw new CustomError(
"rev is higher than the head revision of the pad",
"apierror",
);
}
// get the text of this revision
// getInternalRevisionAText() returns an atext object, but we only want the .text inside it.
// Details at https://github.com/ether/etherpad-lite/issues/5073
const {text} = await pad.getInternalRevisionAText(rev);
return {text};
const { text } = await pad.getInternalRevisionAText(rev);
return { text };
}
// the client wants the latest text, lets return it to him
const text = exportTxt.getTXTFromAtext(pad, pad.atext);
return {text};
return { text };
};
/**
@ -200,10 +205,14 @@ Example returns:
* @param {String} authorId the id of the author, defaulting to empty string
* @returns {Promise<void>}
*/
exports.setText = async (padID: string, text?: string, authorId: string = ''): Promise<void> => {
exports.setText = async (
padID: string,
text?: string,
authorId = "",
): Promise<void> => {
// text is required
if (typeof text !== 'string') {
throw new CustomError('text is not a string', 'apierror');
if (typeof text !== "string") {
throw new CustomError("text is not a string", "apierror");
}
// get the pad
@ -225,10 +234,10 @@ Example returns:
@param {String} text the text of the pad
@param {String} authorId the id of the author, defaulting to empty string
*/
exports.appendText = async (padID:string, text?: string, authorId:string = '') => {
exports.appendText = async (padID: string, text?: string, authorId = "") => {
// text is required
if (typeof text !== 'string') {
throw new CustomError('text is not a string', 'apierror');
if (typeof text !== "string") {
throw new CustomError("text is not a string", "apierror");
}
const pad = await getPadSafe(padID, true);
@ -247,7 +256,10 @@ Example returns:
@param {String} rev the revision number, defaulting to the latest revision
@return {Promise<{html: string}>} the html of the pad
*/
exports.getHTML = async (padID: string, rev: string): Promise<{ html: string; }> => {
exports.getHTML = async (
padID: string,
rev: string,
): Promise<{ html: string }> => {
if (rev !== undefined) {
rev = checkValidRev(rev);
}
@ -259,7 +271,10 @@ exports.getHTML = async (padID: string, rev: string): Promise<{ html: string; }>
// check if this is a valid revision
const head = pad.getHeadRevisionNumber();
if (rev > head) {
throw new CustomError('rev is higher than the head revision of the pad', 'apierror');
throw new CustomError(
"rev is higher than the head revision of the pad",
"apierror",
);
}
}
@ -268,7 +283,7 @@ exports.getHTML = async (padID: string, rev: string): Promise<{ html: string; }>
// wrap the HTML
html = `<!DOCTYPE HTML><html><body>${html}</body></html>`;
return {html};
return { html };
};
/**
@ -283,10 +298,14 @@ Example returns:
@param {String} html the html of the pad
@param {String} authorId the id of the author, defaulting to empty string
*/
exports.setHTML = async (padID: string, html:string|object, authorId = '') => {
exports.setHTML = async (
padID: string,
html: string | object,
authorId = "",
) => {
// html string is required
if (typeof html !== 'string') {
throw new CustomError('html is not a string', 'apierror');
if (typeof html !== "string") {
throw new CustomError("html is not a string", "apierror");
}
// get the pad
@ -296,7 +315,7 @@ exports.setHTML = async (padID: string, html:string|object, authorId = '') => {
try {
await importHtml.setPadHTML(pad, cleanText(html), authorId);
} catch (e) {
throw new CustomError('HTML is malformed', 'apierror');
throw new CustomError("HTML is malformed", "apierror");
}
// update the clients on the pad
@ -324,16 +343,16 @@ Example returns:
@param {Number} start the start point of the chat-history
@param {Number} end the end point of the chat-history
*/
exports.getChatHistory = async (padID: string, start:number, end:number) => {
exports.getChatHistory = async (padID: string, start: number, end: number) => {
if (start && end) {
if (start < 0) {
throw new CustomError('start is below zero', 'apierror');
throw new CustomError("start is below zero", "apierror");
}
if (end < 0) {
throw new CustomError('end is below zero', 'apierror');
throw new CustomError("end is below zero", "apierror");
}
if (start > end) {
throw new CustomError('start is higher than end', 'apierror');
throw new CustomError("start is higher than end", "apierror");
}
}
@ -349,16 +368,22 @@ exports.getChatHistory = async (padID: string, start:number, end:number) => {
}
if (start > chatHead) {
throw new CustomError('start is higher than the current chatHead', 'apierror');
throw new CustomError(
"start is higher than the current chatHead",
"apierror",
);
}
if (end > chatHead) {
throw new CustomError('end is higher than the current chatHead', 'apierror');
throw new CustomError(
"end is higher than the current chatHead",
"apierror",
);
}
// the whole message-log and return it to the client
const messages = await pad.getChatMessages(start, end);
return {messages};
return { messages };
};
/**
@ -374,10 +399,15 @@ Example returns:
@param {String} authorID the id of the author
@param {Number} time the timestamp of the chat-message
*/
exports.appendChatMessage = async (padID: string, text: string|object, authorID: string, time: number) => {
exports.appendChatMessage = async (
padID: string,
text: string | object,
authorID: string,
time: number,
) => {
// text is required
if (typeof text !== 'string') {
throw new CustomError('text is not a string', 'apierror');
if (typeof text !== "string") {
throw new CustomError("text is not a string", "apierror");
}
// if time is not an integer value set time to current timestamp
@ -388,7 +418,10 @@ exports.appendChatMessage = async (padID: string, text: string|object, authorID:
// @TODO - missing getPadSafe() call ?
// save chat message to database and send message to all connected clients
await padMessageHandler.sendChatMessageToPadClients(new ChatMessage(text, authorID, time), padID);
await padMessageHandler.sendChatMessageToPadClients(
new ChatMessage(text, authorID, time),
padID,
);
};
/* ***************
@ -407,7 +440,7 @@ Example returns:
exports.getRevisionsCount = async (padID: string) => {
// get the pad
const pad = await getPadSafe(padID, true);
return {revisions: pad.getHeadRevisionNumber()};
return { revisions: pad.getHeadRevisionNumber() };
};
/**
@ -422,7 +455,7 @@ Example returns:
exports.getSavedRevisionsCount = async (padID: string) => {
// get the pad
const pad = await getPadSafe(padID, true);
return {savedRevisions: pad.getSavedRevisionsNumber()};
return { savedRevisions: pad.getSavedRevisionsNumber() };
};
/**
@ -437,7 +470,7 @@ Example returns:
exports.listSavedRevisions = async (padID: string) => {
// get the pad
const pad = await getPadSafe(padID, true);
return {savedRevisions: pad.getSavedRevisionsList()};
return { savedRevisions: pad.getSavedRevisionsList() };
};
/**
@ -463,14 +496,17 @@ exports.saveRevision = async (padID: string, rev: number) => {
// the client asked for a special revision
if (rev !== undefined) {
if (rev > head) {
throw new CustomError('rev is higher than the head revision of the pad', 'apierror');
throw new CustomError(
"rev is higher than the head revision of the pad",
"apierror",
);
}
} else {
rev = pad.getHeadRevisionNumber();
}
const author = await authorManager.createAuthor('API');
await pad.addSavedRevision(rev, author.authorID, 'Saved through API call');
const author = await authorManager.createAuthor("API");
await pad.addSavedRevision(rev, author.authorID, "Saved through API call");
};
/**
@ -483,11 +519,13 @@ Example returns:
@param {String} padID the id of the pad
@return {Promise<{lastEdited: number}>} the timestamp of the last revision of the pad
*/
exports.getLastEdited = async (padID: string): Promise<{ lastEdited: number; }> => {
exports.getLastEdited = async (
padID: string,
): Promise<{ lastEdited: number }> => {
// get the pad
const pad = await getPadSafe(padID, true);
const lastEdited = await pad.getLastEdit();
return {lastEdited};
return { lastEdited };
};
/**
@ -501,16 +539,19 @@ Example returns:
@param {String} text the initial text of the pad
@param {String} authorId the id of the author, defaulting to empty string
*/
exports.createPad = async (padID: string, text: string, authorId = '') => {
exports.createPad = async (padID: string, text: string, authorId = "") => {
if (padID) {
// ensure there is no $ in the padID
if (padID.indexOf('$') !== -1) {
throw new CustomError("createPad can't create group pads", 'apierror');
if (padID.indexOf("$") !== -1) {
throw new CustomError("createPad can't create group pads", "apierror");
}
// check for url special characters
if (padID.match(/(\/|\?|&|#)/)) {
throw new CustomError('malformed padID: Remove special characters', 'apierror');
throw new CustomError(
"malformed padID: Remove special characters",
"apierror",
);
}
}
@ -543,10 +584,10 @@ exports.deletePad = async (padID: string) => {
@param {Number} rev the revision number, defaulting to the latest revision
@param {String} authorId the id of the author, defaulting to empty string
*/
exports.restoreRevision = async (padID: string, rev: number, authorId = '') => {
exports.restoreRevision = async (padID: string, rev: number, authorId = "") => {
// check if rev is a number
if (rev === undefined) {
throw new CustomError('rev is not defined', 'apierror');
throw new CustomError("rev is not defined", "apierror");
}
rev = checkValidRev(rev);
@ -555,22 +596,29 @@ exports.restoreRevision = async (padID: string, rev: number, authorId = '') => {
// check if this is a valid revision
if (rev > pad.getHeadRevisionNumber()) {
throw new CustomError('rev is higher than the head revision of the pad', 'apierror');
throw new CustomError(
"rev is higher than the head revision of the pad",
"apierror",
);
}
const atext = await pad.getInternalRevisionAText(rev);
const oldText = pad.text();
atext.text += '\n';
atext.text += "\n";
const eachAttribRun = (attribs: string[], func:Function) => {
const eachAttribRun = (attribs: string[], func: Function) => {
let textIndex = 0;
const newTextStart = 0;
const newTextEnd = atext.text.length;
for (const op of Changeset.deserializeOps(attribs)) {
const nextIndex = textIndex + op.chars;
if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {
func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs);
func(
Math.max(newTextStart, textIndex),
Math.min(newTextEnd, nextIndex),
op.attribs,
);
}
textIndex = nextIndex;
}
@ -580,11 +628,14 @@ exports.restoreRevision = async (padID: string, rev: number, authorId = '') => {
const builder = Changeset.builder(oldText.length);
// assemble each line into the builder
eachAttribRun(atext.attribs, (start: number, end: number, attribs:string[]) => {
eachAttribRun(
atext.attribs,
(start: number, end: number, attribs: string[]) => {
builder.insert(atext.text.substring(start, end), attribs);
});
},
);
const lastNewlinePos = oldText.lastIndexOf('\n');
const lastNewlinePos = oldText.lastIndexOf("\n");
if (lastNewlinePos < 0) {
builder.remove(oldText.length - 1, 0);
} else {
@ -610,7 +661,11 @@ Example returns:
@param {String} destinationID the id of the destination pad
@param {Boolean} force whether to overwrite the destination pad if it exists
*/
exports.copyPad = async (sourceID: string, destinationID: string, force: boolean) => {
exports.copyPad = async (
sourceID: string,
destinationID: string,
force: boolean,
) => {
const pad = await getPadSafe(sourceID, true);
await pad.copy(destinationID, force);
};
@ -628,7 +683,12 @@ Example returns:
@param {Boolean} force whether to overwrite the destination pad if it exists
@param {String} authorId the id of the author, defaulting to empty string
*/
exports.copyPadWithoutHistory = async (sourceID: string, destinationID: string, force:boolean, authorId = '') => {
exports.copyPadWithoutHistory = async (
sourceID: string,
destinationID: string,
force: boolean,
authorId = "",
) => {
const pad = await getPadSafe(sourceID, true);
await pad.copyPadWithoutHistory(destinationID, force, authorId);
};
@ -645,7 +705,11 @@ Example returns:
@param {String} destinationID the id of the destination pad
@param {Boolean} force whether to overwrite the destination pad if it exists
*/
exports.movePad = async (sourceID: string, destinationID: string, force:boolean) => {
exports.movePad = async (
sourceID: string,
destinationID: string,
force: boolean,
) => {
const pad = await getPadSafe(sourceID, true);
await pad.copy(destinationID, force);
await pad.remove();
@ -667,7 +731,7 @@ exports.getReadOnlyID = async (padID: string) => {
// get the readonlyId
const readOnlyID = await readOnlyManager.getReadOnlyId(padID);
return {readOnlyID};
return { readOnlyID };
};
/**
@ -683,10 +747,10 @@ exports.getPadID = async (roID: string) => {
// get the PadId
const padID = await readOnlyManager.getPadId(roID);
if (padID == null) {
throw new CustomError('padID does not exist', 'apierror');
throw new CustomError("padID does not exist", "apierror");
}
return {padID};
return { padID };
};
/**
@ -699,16 +763,19 @@ Example returns:
@param {String} padID the id of the pad
@param {Boolean} publicStatus the public status of the pad
*/
exports.setPublicStatus = async (padID: string, publicStatus: boolean|string) => {
exports.setPublicStatus = async (
padID: string,
publicStatus: boolean | string,
) => {
// ensure this is a group pad
checkGroupPad(padID, 'publicStatus');
checkGroupPad(padID, "publicStatus");
// get the pad
const pad = await getPadSafe(padID, true);
// convert string to boolean
if (typeof publicStatus === 'string') {
publicStatus = (publicStatus.toLowerCase() === 'true');
if (typeof publicStatus === "string") {
publicStatus = publicStatus.toLowerCase() === "true";
}
await pad.setPublicStatus(publicStatus);
@ -725,11 +792,11 @@ Example returns:
*/
exports.getPublicStatus = async (padID: string) => {
// ensure this is a group pad
checkGroupPad(padID, 'publicStatus');
checkGroupPad(padID, "publicStatus");
// get the pad
const pad = await getPadSafe(padID, true);
return {publicStatus: pad.getPublicStatus()};
return { publicStatus: pad.getPublicStatus() };
};
/**
@ -745,7 +812,7 @@ exports.listAuthorsOfPad = async (padID: string) => {
// get the pad
const pad = await getPadSafe(padID, true);
const authorIDs = pad.getAllAuthors();
return {authorIDs};
return { authorIDs };
};
/**
@ -786,8 +853,7 @@ Example returns:
{"code":0,"message":"ok","data":null}
{"code":4,"message":"no or wrong API Key","data":null}
*/
exports.checkToken = async () => {
};
exports.checkToken = async () => {};
/**
getChatHead(padID) returns the chatHead (last number of the last chat-message) of the pad
@ -799,10 +865,10 @@ Example returns:
@param {String} padID the id of the pad
@return {Promise<{chatHead: number}>} the chatHead of the pad
*/
exports.getChatHead = async (padID:string): Promise<{ chatHead: number; }> => {
exports.getChatHead = async (padID: string): Promise<{ chatHead: number }> => {
// get the pad
const pad = await getPadSafe(padID, true);
return {chatHead: pad.chatHead};
return { chatHead: pad.chatHead };
};
/**
@ -825,7 +891,11 @@ Example returns:
@param {Number} startRev the start revision number
@param {Number} endRev the end revision number
*/
exports.createDiffHTML = async (padID: string, startRev: number, endRev: number) => {
exports.createDiffHTML = async (
padID: string,
startRev: number,
endRev: number,
) => {
// check if startRev is a number
if (startRev !== undefined) {
startRev = checkValidRev(startRev);
@ -846,14 +916,14 @@ exports.createDiffHTML = async (padID: string, startRev: number, endRev: number)
let padDiff;
try {
padDiff = new PadDiff(pad, startRev, endRev);
} catch (e:any) {
throw {stop: e.message};
} catch (e: any) {
throw { stop: e.message };
}
const html = await padDiff.getHtml();
const authors = await padDiff.getAuthors();
return {html, authors};
return { html, authors };
};
/* ********************
@ -873,9 +943,11 @@ exports.getStats = async () => {
const sessionKeys = Object.keys(sessionInfos);
// @ts-ignore
const activePads = new Set(Object.entries(sessionInfos).map((k) => k[1].padId));
const activePads = new Set(
Object.entries(sessionInfos).map((k) => k[1].padId),
);
const {padIDs} = await padManager.listAllPads();
const { padIDs } = await padManager.listAllPads();
return {
totalPads: padIDs.length,
@ -889,15 +961,20 @@ exports.getStats = async () => {
**************************** */
// gets a pad safe
const getPadSafe = async (padID: string|object, shouldExist: boolean, text?:string, authorId:string = '') => {
const getPadSafe = async (
padID: string | object,
shouldExist: boolean,
text?: string,
authorId = "",
) => {
// check if padID is a string
if (typeof padID !== 'string') {
throw new CustomError('padID is not a string', 'apierror');
if (typeof padID !== "string") {
throw new CustomError("padID is not a string", "apierror");
}
// check if the padID maches the requirements
if (!padManager.isValidPadId(padID)) {
throw new CustomError('padID did not match requirements', 'apierror');
throw new CustomError("padID did not match requirements", "apierror");
}
// check if the pad exists
@ -905,12 +982,12 @@ const getPadSafe = async (padID: string|object, shouldExist: boolean, text?:stri
if (!exists && shouldExist) {
// does not exist, but should
throw new CustomError('padID does not exist', 'apierror');
throw new CustomError("padID does not exist", "apierror");
}
if (exists && !shouldExist) {
// does exist, but shouldn't
throw new CustomError('padID does already exist', 'apierror');
throw new CustomError("padID does already exist", "apierror");
}
// pad exists, let's get it
@ -920,8 +997,10 @@ const getPadSafe = async (padID: string|object, shouldExist: boolean, text?:stri
// checks if a padID is part of a group
const checkGroupPad = (padID: string, field: string) => {
// ensure this is a group pad
if (padID && padID.indexOf('$') === -1) {
if (padID && padID.indexOf("$") === -1) {
throw new CustomError(
`You can only get/set the ${field} of pads that belong to a group`, 'apierror');
`You can only get/set the ${field} of pads that belong to a group`,
"apierror",
);
}
};

View file

@ -1,4 +1,3 @@
'use strict';
/**
* The AuthorManager controlls all information about the Pad authors
*/
@ -19,76 +18,79 @@
* limitations under the License.
*/
const db = require('./DB');
const CustomError = require('../utils/customError');
const hooks = require('../../static/js/pluginfw/hooks.js');
const {randomString, padutils: {warnDeprecated}} = require('../../static/js/pad_utils');
const db = require("./DB");
const CustomError = require("../utils/customError");
const hooks = require("../../static/js/pluginfw/hooks.js");
const {
randomString,
padutils: { warnDeprecated },
} = require("../../static/js/pad_utils");
exports.getColorPalette = () => [
'#ffc7c7',
'#fff1c7',
'#e3ffc7',
'#c7ffd5',
'#c7ffff',
'#c7d5ff',
'#e3c7ff',
'#ffc7f1',
'#ffa8a8',
'#ffe699',
'#cfff9e',
'#99ffb3',
'#a3ffff',
'#99b3ff',
'#cc99ff',
'#ff99e5',
'#e7b1b1',
'#e9dcAf',
'#cde9af',
'#bfedcc',
'#b1e7e7',
'#c3cdee',
'#d2b8ea',
'#eec3e6',
'#e9cece',
'#e7e0ca',
'#d3e5c7',
'#bce1c5',
'#c1e2e2',
'#c1c9e2',
'#cfc1e2',
'#e0bdd9',
'#baded3',
'#a0f8eb',
'#b1e7e0',
'#c3c8e4',
'#cec5e2',
'#b1d5e7',
'#cda8f0',
'#f0f0a8',
'#f2f2a6',
'#f5a8eb',
'#c5f9a9',
'#ececbb',
'#e7c4bc',
'#daf0b2',
'#b0a0fd',
'#bce2e7',
'#cce2bb',
'#ec9afe',
'#edabbd',
'#aeaeea',
'#c4e7b1',
'#d722bb',
'#f3a5e7',
'#ffa8a8',
'#d8c0c5',
'#eaaedd',
'#adc6eb',
'#bedad1',
'#dee9af',
'#e9afc2',
'#f8d2a0',
'#b3b3e6',
"#ffc7c7",
"#fff1c7",
"#e3ffc7",
"#c7ffd5",
"#c7ffff",
"#c7d5ff",
"#e3c7ff",
"#ffc7f1",
"#ffa8a8",
"#ffe699",
"#cfff9e",
"#99ffb3",
"#a3ffff",
"#99b3ff",
"#cc99ff",
"#ff99e5",
"#e7b1b1",
"#e9dcAf",
"#cde9af",
"#bfedcc",
"#b1e7e7",
"#c3cdee",
"#d2b8ea",
"#eec3e6",
"#e9cece",
"#e7e0ca",
"#d3e5c7",
"#bce1c5",
"#c1e2e2",
"#c1c9e2",
"#cfc1e2",
"#e0bdd9",
"#baded3",
"#a0f8eb",
"#b1e7e0",
"#c3c8e4",
"#cec5e2",
"#b1d5e7",
"#cda8f0",
"#f0f0a8",
"#f2f2a6",
"#f5a8eb",
"#c5f9a9",
"#ececbb",
"#e7c4bc",
"#daf0b2",
"#b0a0fd",
"#bce2e7",
"#cce2bb",
"#ec9afe",
"#edabbd",
"#aeaeea",
"#c4e7b1",
"#d722bb",
"#f3a5e7",
"#ffa8a8",
"#d8c0c5",
"#eaaedd",
"#adc6eb",
"#bedad1",
"#dee9af",
"#e9afc2",
"#f8d2a0",
"#b3b3e6",
];
/**
@ -107,14 +109,13 @@ exports.doesAuthorExist = async (authorID: string) => {
*/
exports.doesAuthorExists = exports.doesAuthorExist;
/**
* Returns the AuthorID for a mapper. We can map using a mapperkey,
* so far this is token2author and mapper2author
* @param {String} mapperkey The database key name for this mapper
* @param {String} mapper The mapper
*/
const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => {
const mapAuthorWithDBKey = async (mapperkey: string, mapper: string) => {
// try to map to an author
const author = await db.get(`${mapperkey}:${mapper}`);
@ -131,10 +132,10 @@ const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => {
// there is an author with this mapper
// 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 {authorID: author};
return { authorID: author };
};
/**
@ -143,7 +144,7 @@ const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => {
* @return {Promise<string|*|{authorID: string}|{authorID: *}>}
*/
const getAuthor4Token = async (token: string) => {
const author = await mapAuthorWithDBKey('token2author', token);
const author = await mapAuthorWithDBKey("token2author", token);
// return only the sub value authorID
return author ? author.authorID : author;
@ -156,8 +157,8 @@ const getAuthor4Token = async (token: string) => {
* @return {Promise<*>}
*/
exports.getAuthorId = async (token: string, user: object) => {
const context = {dbKey: token, token, user};
let [authorId] = await hooks.aCallFirst('getAuthorId', context);
const context = { dbKey: token, token, user };
let [authorId] = await hooks.aCallFirst("getAuthorId", context);
if (!authorId) authorId = await getAuthor4Token(context.dbKey);
return authorId;
};
@ -170,7 +171,8 @@ exports.getAuthorId = async (token: string, user: object) => {
*/
exports.getAuthor4Token = async (token: string) => {
warnDeprecated(
'AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead');
"AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead",
);
return await getAuthor4Token(token);
};
@ -179,8 +181,11 @@ exports.getAuthor4Token = async (token: string) => {
* @param {String} authorMapper The mapper
* @param {String} name The name of the author (optional)
*/
exports.createAuthorIfNotExistsFor = async (authorMapper: string, name: string) => {
const author = await mapAuthorWithDBKey('mapper2author', authorMapper);
exports.createAuthorIfNotExistsFor = async (
authorMapper: string,
name: string,
) => {
const author = await mapAuthorWithDBKey("mapper2author", authorMapper);
if (name) {
// set the name of this author
@ -190,7 +195,6 @@ exports.createAuthorIfNotExistsFor = async (authorMapper: string, name: string)
return author;
};
/**
* Internal function that creates the database entry for an author
* @param {String} name The name of the author
@ -201,7 +205,7 @@ exports.createAuthor = async (name: string) => {
// create the globalAuthors db entry
const authorObj = {
colorId: Math.floor(Math.random() * (exports.getColorPalette().length)),
colorId: Math.floor(Math.random() * exports.getColorPalette().length),
name,
timestamp: Date.now(),
};
@ -209,42 +213,45 @@ exports.createAuthor = async (name: string) => {
// set the global author db entry
await db.set(`globalAuthor:${author}`, authorObj);
return {authorID: author};
return { authorID: author };
};
/**
* Returns the Author Obj of the author
* @param {String} author The id of the author
*/
exports.getAuthor = async (author: string) => await db.get(`globalAuthor:${author}`);
exports.getAuthor = async (author: string) =>
await db.get(`globalAuthor:${author}`);
/**
* Returns the color Id of the author
* @param {String} author The id of the author
*/
exports.getAuthorColorId = async (author: string) => await db.getSub(`globalAuthor:${author}`, ['colorId']);
exports.getAuthorColorId = async (author: string) =>
await db.getSub(`globalAuthor:${author}`, ["colorId"]);
/**
* Sets the color Id of the author
* @param {String} author The id of the author
* @param {String} colorId The color id of the author
*/
exports.setAuthorColorId = async (author: string, colorId: string) => await db.setSub(
`globalAuthor:${author}`, ['colorId'], colorId);
exports.setAuthorColorId = async (author: string, colorId: string) =>
await db.setSub(`globalAuthor:${author}`, ["colorId"], colorId);
/**
* Returns the name of the author
* @param {String} author The id of the author
*/
exports.getAuthorName = async (author: string) => await db.getSub(`globalAuthor:${author}`, ['name']);
exports.getAuthorName = async (author: string) =>
await db.getSub(`globalAuthor:${author}`, ["name"]);
/**
* Sets the name of the author
* @param {String} author The id of the author
* @param {String} name The name of the author
*/
exports.setAuthorName = async (author: string, name: string) => await db.setSub(
`globalAuthor:${author}`, ['name'], name);
exports.setAuthorName = async (author: string, name: string) =>
await db.setSub(`globalAuthor:${author}`, ["name"], name);
/**
* Returns an array of all pads this author contributed to
@ -261,13 +268,13 @@ exports.listPadsOfAuthor = async (authorID: string) => {
if (author == null) {
// 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
const padIDs = Object.keys(author.padIDs || {});
return {padIDs};
return { padIDs };
};
/**

View file

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

View file

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

View file

@ -1,30 +1,31 @@
'use strict';
import {Database} from "ueberdb2";
import {AChangeSet, APool, AText} from "../types/PadType";
import {MapArrayType} from "../types/MapType";
import type { Database } from "ueberdb2";
import type { MapArrayType } from "../types/MapType";
import type { AChangeSet, APool, AText } from "../types/PadType";
/**
* The pad object, defined with joose
*/
const AttributeMap = require('../../static/js/AttributeMap');
const Changeset = require('../../static/js/Changeset');
const ChatMessage = require('../../static/js/ChatMessage');
const AttributePool = require('../../static/js/AttributePool');
const Stream = require('../utils/Stream');
const assert = require('assert').strict;
const db = require('./DB');
const settings = require('../utils/Settings');
const authorManager = require('./AuthorManager');
const padManager = require('./PadManager');
const padMessageHandler = require('../handler/PadMessageHandler');
const groupManager = require('./GroupManager');
const CustomError = require('../utils/customError');
const readOnlyManager = require('./ReadOnlyManager');
const randomString = require('../utils/randomstring');
const hooks = require('../../static/js/pluginfw/hooks');
const {padutils: {warnDeprecated}} = require('../../static/js/pad_utils');
const promises = require('../utils/promises');
const AttributeMap = require("../../static/js/AttributeMap");
const Changeset = require("../../static/js/Changeset");
const ChatMessage = require("../../static/js/ChatMessage");
const AttributePool = require("../../static/js/AttributePool");
const Stream = require("../utils/Stream");
const assert = require("assert").strict;
const db = require("./DB");
const settings = require("../utils/Settings");
const authorManager = require("./AuthorManager");
const padManager = require("./PadManager");
const padMessageHandler = require("../handler/PadMessageHandler");
const groupManager = require("./GroupManager");
const CustomError = require("../utils/customError");
const readOnlyManager = require("./ReadOnlyManager");
const randomString = require("../utils/randomstring");
const hooks = require("../../static/js/pluginfw/hooks");
const {
padutils: { warnDeprecated },
} = require("../../static/js/pad_utils");
const promises = require("../utils/promises");
/**
* Copied from the Etherpad source code. It converts Windows line breaks to Unix
@ -32,10 +33,12 @@ const promises = require('../utils/promises');
* @param {String} txt The text to clean
* @returns {String} The cleaned text
*/
exports.cleanText = (txt:string): string => txt.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\t/g, ' ')
.replace(/\xa0/g, ' ');
exports.cleanText = (txt: string): string =>
txt
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/\t/g, " ")
.replace(/\xa0/g, " ");
class Pad {
private db: Database;
@ -54,9 +57,9 @@ class Pad {
* can be used to shard pad storage across multiple database backends, to put each pad in its
* own database table, or to validate imported pad data before it is written to the database.
*/
constructor(id:string, database = db) {
constructor(id: string, database = db) {
this.db = database;
this.atext = Changeset.makeAText('\n');
this.atext = Changeset.makeAText("\n");
this.pool = new AttributePool();
this.head = -1;
this.chatHead = -1;
@ -93,10 +96,13 @@ class Pad {
* @param {String} authorId The id of the author
* @return {Promise<number|string>}
*/
async appendRevision(aChangeset:AChangeSet, authorId = '') {
async appendRevision(aChangeset: AChangeSet, authorId = "") {
const newAText = Changeset.applyToAText(aChangeset, this.atext, this.pool);
if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs &&
this.head !== -1) {
if (
newAText.text === this.atext.text &&
newAText.attribs === this.atext.attribs &&
this.head !== -1
) {
return this.head;
}
Changeset.copyAText(newAText, this.atext);
@ -104,9 +110,9 @@ class Pad {
const newRev = ++this.head;
// ex. getNumForAuthor
if (authorId !== '') this.pool.putAttrib(['author', authorId]);
if (authorId !== "") this.pool.putAttrib(["author", authorId]);
const hook = this.head === 0 ? 'padCreate' : 'padUpdate';
const hook = this.head === 0 ? "padCreate" : "padUpdate";
await Promise.all([
// @ts-ignore
this.db.set(`pad:${this.id}:revs:${newRev}`, {
@ -114,10 +120,12 @@ class Pad {
meta: {
author: authorId,
timestamp: Date.now(),
...newRev === this.getKeyRevisionNumber(newRev) ? {
...(newRev === this.getKeyRevisionNumber(newRev)
? {
pool: this.pool,
atext: this.atext,
} : {},
}
: {}),
},
}),
this.saveToDatabase(),
@ -126,24 +134,30 @@ class Pad {
pad: this,
authorId,
get author() {
warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`);
warnDeprecated(
`${hook} hook author context is deprecated; use authorId instead`,
);
return this.authorId;
},
set author(authorId) {
warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`);
warnDeprecated(
`${hook} hook author context is deprecated; use authorId instead`,
);
this.authorId = authorId;
},
...this.head === 0 ? {} : {
...(this.head === 0
? {}
: {
revs: newRev,
changeset: aChangeset,
},
}),
}),
]);
return newRev;
}
toJSON() {
const o:Pad = {...this, pool: this.pool.toJsonable()};
const o: Pad = { ...this, pool: this.pool.toJsonable() };
// @ts-ignore
delete o.db;
// @ts-ignore
@ -161,22 +175,31 @@ class Pad {
async getLastEdit() {
const revNum = this.getHeadRevisionNumber();
// @ts-ignore
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']);
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, [
"meta",
"timestamp",
]);
}
async getRevisionChangeset(revNum: number) {
// @ts-ignore
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['changeset']);
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ["changeset"]);
}
async getRevisionAuthor(revNum: number) {
// @ts-ignore
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'author']);
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, [
"meta",
"author",
]);
}
async getRevisionDate(revNum: number) {
// @ts-ignore
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']);
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, [
"meta",
"timestamp",
]);
}
/**
@ -185,7 +208,10 @@ class Pad {
*/
async _getKeyRevisionAText(revNum: number) {
// @ts-ignore
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'atext']);
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, [
"meta",
"atext",
]);
}
/**
@ -196,7 +222,10 @@ class Pad {
const authorIds = [];
for (const key in this.pool.numToAttrib) {
if (this.pool.numToAttrib[key][0] === 'author' && this.pool.numToAttrib[key][1] !== '') {
if (
this.pool.numToAttrib[key][0] === "author" &&
this.pool.numToAttrib[key][1] !== ""
) {
authorIds.push(this.pool.numToAttrib[key][1]);
}
}
@ -211,11 +240,15 @@ class Pad {
const [keyAText, changesets] = await Promise.all([
this._getKeyRevisionAText(keyRev),
Promise.all(
Stream.range(keyRev + 1, targetRev + 1).map(this.getRevisionChangeset.bind(this))),
Stream.range(keyRev + 1, targetRev + 1).map(
this.getRevisionChangeset.bind(this),
),
),
]);
const apool = this.apool();
let atext = keyAText;
for (const cs of changesets) atext = Changeset.applyToAText(cs, atext, apool);
for (const cs of changesets)
atext = Changeset.applyToAText(cs, atext, apool);
return atext;
}
@ -225,22 +258,25 @@ class Pad {
async getAllAuthorColors() {
const authorIds = this.getAllAuthors();
const returnTable:MapArrayType<string> = {};
const returnTable: MapArrayType<string> = {};
const colorPalette = authorManager.getColorPalette();
await Promise.all(
authorIds.map((authorId) => authorManager.getAuthorColorId(authorId).then((colorId:string) => {
authorIds.map((authorId) =>
authorManager.getAuthorColorId(authorId).then((colorId: string) => {
// colorId might be a hex color or an number out of the palette
returnTable[authorId] = colorPalette[colorId] || colorId;
})));
}),
),
);
return returnTable;
}
getValidRevisionRange(startRev: any, endRev:any) {
startRev = parseInt(startRev, 10);
getValidRevisionRange(startRev: any, endRev: any) {
startRev = Number.parseInt(startRev, 10);
const head = this.getHeadRevisionNumber();
endRev = endRev ? parseInt(endRev, 10) : head;
endRev = endRev ? Number.parseInt(endRev, 10) : head;
if (isNaN(startRev) || startRev < 0 || startRev > head) {
startRev = null;
@ -253,7 +289,7 @@ class Pad {
}
if (startRev != null && endRev != null) {
return {startRev, endRev};
return { startRev, endRev };
}
return null;
}
@ -280,18 +316,23 @@ class Pad {
* @param {string} ins - New text to insert at `start` (after the `ndel` characters are deleted).
* @param {string} [authorId] - Author ID of the user making the change (if applicable).
*/
async spliceText(start:number, ndel:number, ins: string, authorId: string = '') {
if (start < 0) throw new RangeError(`start index must be non-negative (is ${start})`);
if (ndel < 0) throw new RangeError(`characters to delete must be non-negative (is ${ndel})`);
async spliceText(start: number, ndel: number, ins: string, authorId = "") {
if (start < 0)
throw new RangeError(`start index must be non-negative (is ${start})`);
if (ndel < 0)
throw new RangeError(
`characters to delete must be non-negative (is ${ndel})`,
);
const orig = this.text();
assert(orig.endsWith('\n'));
if (start + ndel > orig.length) throw new RangeError('start/delete past the end of the text');
assert(orig.endsWith("\n"));
if (start + ndel > orig.length)
throw new RangeError("start/delete past the end of the text");
ins = exports.cleanText(ins);
const willEndWithNewline =
start + ndel < orig.length || // Keeping last char (which is guaranteed to be a newline).
ins.endsWith('\n') ||
(!ins && start > 0 && orig[start - 1] === '\n');
if (!willEndWithNewline) ins += '\n';
ins.endsWith("\n") ||
(!ins && start > 0 && orig[start - 1] === "\n");
if (!willEndWithNewline) ins += "\n";
if (ndel === 0 && ins.length === 0) return;
const changeset = Changeset.makeSplice(orig, start, ndel, ins);
await this.appendRevision(changeset, authorId);
@ -305,7 +346,7 @@ class Pad {
* @param {string} [authorId] - The author ID of the user that initiated the change, if
* applicable.
*/
async setText(newText: string, authorId = '') {
async setText(newText: string, authorId = "") {
await this.spliceText(0, this.text().length, newText, authorId);
}
@ -316,7 +357,7 @@ class Pad {
* @param {string} [authorId] - The author ID of the user that initiated the change, if
* applicable.
*/
async appendText(newText:string, authorId = '') {
async appendText(newText: string, authorId = "") {
await this.spliceText(this.text().length - 1, 0, newText, authorId);
}
@ -330,15 +371,24 @@ class Pad {
* @param {?number} [time] - Message timestamp (milliseconds since epoch). Deprecated; use
* `msgOrText.time` instead.
*/
async appendChatMessage(msgOrText: string|typeof ChatMessage, authorId = null, time = null) {
async appendChatMessage(
msgOrText: string | typeof ChatMessage,
authorId = null,
time = null,
) {
const msg =
msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time);
msgOrText instanceof ChatMessage
? msgOrText
: new ChatMessage(msgOrText, authorId, time);
this.chatHead++;
await Promise.all([
// Don't save the display name in the database because the user can change it at any time. The
// `displayName` property will be populated with the current value when the message is read
// from the database.
this.db.set(`pad:${this.id}:chat:${this.chatHead}`, {...msg, displayName: undefined}),
this.db.set(`pad:${this.id}:chat:${this.chatHead}`, {
...msg,
displayName: undefined,
}),
this.saveToDatabase(),
]);
}
@ -363,14 +413,15 @@ class Pad {
* interval as is typical in code.
*/
async getChatMessages(start: string, end: number) {
const entries =
await Promise.all(Stream.range(start, end + 1).map(this.getChatMessage.bind(this)));
const entries = await Promise.all(
Stream.range(start, end + 1).map(this.getChatMessage.bind(this)),
);
// sort out broken chat entries
// it looks like in happened in the past that the chat head was
// incremented, but the chat message wasn't added
return entries.filter((entry) => {
const pass = (entry != null);
const pass = entry != null;
if (!pass) {
console.warn(`WARNING: Found broken chat entry in pad ${this.id}`);
}
@ -378,25 +429,32 @@ class Pad {
});
}
async init(text:string, authorId = '') {
async init(text: string, authorId = "") {
// try to load the pad
const value = await this.db.get(`pad:${this.id}`);
// if this pad exists, load it
if (value != null) {
Object.assign(this, value);
if ('pool' in value) this.pool = new AttributePool().fromJsonable(value.pool);
if ("pool" in value)
this.pool = new AttributePool().fromJsonable(value.pool);
} else {
if (text == null) {
const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText};
await hooks.aCallAll('padDefaultContent', context);
if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`);
const context = {
pad: this,
authorId,
type: "text",
content: settings.defaultPadText,
};
await hooks.aCallAll("padDefaultContent", context);
if (context.type !== "text")
throw new Error(`unsupported content type: ${context.type}`);
text = exports.cleanText(context.content);
}
const firstChangeset = Changeset.makeSplice('\n', 0, 0, text);
const firstChangeset = Changeset.makeSplice("\n", 0, 0, text);
await this.appendRevision(firstChangeset, authorId);
}
await hooks.aCallAll('padLoad', {pad: this});
await hooks.aCallAll("padLoad", { pad: this });
}
async copy(destinationID: string, force: boolean) {
@ -419,69 +477,83 @@ class Pad {
await db.set(`pad:${destinationID}${keySuffix}`, val);
};
const promises = (function* () {
yield copyRecord('');
const promises = function* () {
yield copyRecord("");
// @ts-ignore
yield* Stream.range(0, this.head + 1).map((i) => copyRecord(`:revs:${i}`));
yield* Stream.range(0, this.head + 1).map((i) =>
copyRecord(`:revs:${i}`),
);
// @ts-ignore
yield* Stream.range(0, this.chatHead + 1).map((i) => copyRecord(`:chat:${i}`));
yield* Stream.range(0, this.chatHead + 1).map((i) =>
copyRecord(`:chat:${i}`),
);
// @ts-ignore
yield this.copyAuthorInfoToDestinationPad(destinationID);
if (destGroupID) yield db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1);
}).call(this);
if (destGroupID)
yield db.setSub(`group:${destGroupID}`, ["pads", destinationID], 1);
}.call(this);
for (const p of new Stream(promises).batch(100).buffer(99)) await p;
// Initialize the new pad (will update the listAllPads cache)
const dstPad = await padManager.getPad(destinationID, null);
// let the plugins know the pad was copied
await hooks.aCallAll('padCopy', {
await hooks.aCallAll("padCopy", {
get originalPad() {
warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead');
warnDeprecated(
"padCopy originalPad context property is deprecated; use srcPad instead",
);
return this.srcPad;
},
get destinationID() {
warnDeprecated(
'padCopy destinationID context property is deprecated; use dstPad.id instead');
"padCopy destinationID context property is deprecated; use dstPad.id instead",
);
return this.dstPad.id;
},
srcPad: this,
dstPad,
});
return {padID: destinationID};
return { padID: destinationID };
}
async checkIfGroupExistAndReturnIt(destinationID: string) {
let destGroupID:false|string = false;
let destGroupID: false | string = false;
if (destinationID.indexOf('$') >= 0) {
destGroupID = destinationID.split('$')[0];
if (destinationID.indexOf("$") >= 0) {
destGroupID = destinationID.split("$")[0];
const groupExists = await groupManager.doesGroupExist(destGroupID);
// group does not exist
if (!groupExists) {
throw new CustomError('groupID does not exist for destinationID', 'apierror');
throw new CustomError(
"groupID does not exist for destinationID",
"apierror",
);
}
}
return destGroupID;
}
async removePadIfForceIsTrueAndAlreadyExist(destinationID: string, force: boolean|string) {
async removePadIfForceIsTrueAndAlreadyExist(
destinationID: string,
force: boolean | string,
) {
// if the pad exists, we should abort, unless forced.
const exists = await padManager.doesPadExist(destinationID);
// allow force to be a string
if (typeof force === 'string') {
force = (force.toLowerCase() === 'true');
if (typeof force === "string") {
force = force.toLowerCase() === "true";
} else {
force = !!force;
}
if (exists) {
if (!force) {
console.error('erroring out without force');
throw new CustomError('destinationID already exists', 'apierror');
console.error("erroring out without force");
throw new CustomError("destinationID already exists", "apierror");
}
// exists and forcing
@ -492,11 +564,18 @@ class Pad {
async copyAuthorInfoToDestinationPad(destinationID: string) {
// add the new sourcePad to all authors who contributed to the old one
await Promise.all(this.getAllAuthors().map(
(authorID) => authorManager.addPad(authorID, destinationID)));
await Promise.all(
this.getAllAuthors().map((authorID) =>
authorManager.addPad(authorID, destinationID),
),
);
}
async copyPadWithoutHistory(destinationID: string, force: string|boolean, authorId = '') {
async copyPadWithoutHistory(
destinationID: string,
force: string | boolean,
authorId = "",
) {
// flush the source pad
this.saveToDatabase();
@ -510,11 +589,11 @@ class Pad {
// Group pad? Add it to the group's list
if (destGroupID) {
await db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1);
await db.setSub(`group:${destGroupID}`, ["pads", destinationID], 1);
}
// initialize the pad with a new line to avoid getting the defaultText
const dstPad = await padManager.getPad(destinationID, '\n', authorId);
const dstPad = await padManager.getPad(destinationID, "\n", authorId);
dstPad.pool = this.pool.clone();
const oldAText = this.atext;
@ -533,24 +612,32 @@ class Pad {
// create a changeset that removes the previous text and add the newText with
// all atributes present on the source pad
const changeset = Changeset.pack(oldLength, newLength, assem.toString(), newText);
const changeset = Changeset.pack(
oldLength,
newLength,
assem.toString(),
newText,
);
dstPad.appendRevision(changeset, authorId);
await hooks.aCallAll('padCopy', {
await hooks.aCallAll("padCopy", {
get originalPad() {
warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead');
warnDeprecated(
"padCopy originalPad context property is deprecated; use srcPad instead",
);
return this.srcPad;
},
get destinationID() {
warnDeprecated(
'padCopy destinationID context property is deprecated; use dstPad.id instead');
"padCopy destinationID context property is deprecated; use dstPad.id instead",
);
return this.dstPad.id;
},
srcPad: this,
dstPad,
});
return {padID: destinationID};
return { padID: destinationID };
}
async remove() {
@ -566,9 +653,9 @@ class Pad {
// run to completion
// is it a group pad? -> delete the entry of this pad in the group
if (padID.indexOf('$') >= 0) {
if (padID.indexOf("$") >= 0) {
// it is a group pad
const groupID = padID.substring(0, padID.indexOf('$'));
const groupID = padID.substring(0, padID.indexOf("$"));
const group = await db.get(`group:${groupID}`);
// remove the pad entry
@ -579,20 +666,26 @@ class Pad {
}
// remove the readonly entries
p.push(readOnlyManager.getReadOnlyId(padID).then(async (readonlyID: string) => {
p.push(
readOnlyManager.getReadOnlyId(padID).then(async (readonlyID: string) => {
await db.remove(`readonly2pad:${readonlyID}`);
}));
}),
);
p.push(db.remove(`pad2readonly:${padID}`));
// delete all chat messages
p.push(promises.timesLimit(this.chatHead + 1, 500, async (i: string) => {
p.push(
promises.timesLimit(this.chatHead + 1, 500, async (i: string) => {
await this.db.remove(`pad:${this.id}:chat:${i}`, null);
}));
}),
);
// delete all revisions
p.push(promises.timesLimit(this.head + 1, 500, async (i: string) => {
p.push(
promises.timesLimit(this.head + 1, 500, async (i: string) => {
await this.db.remove(`pad:${this.id}:revs:${i}`, null);
}));
}),
);
// remove pad from all authors who contributed
this.getAllAuthors().forEach((authorId) => {
@ -601,13 +694,17 @@ class Pad {
// delete the pad entry and delete pad from padManager
p.push(padManager.removePad(padID));
p.push(hooks.aCallAll('padRemove', {
p.push(
hooks.aCallAll("padRemove", {
get padID() {
warnDeprecated('padRemove padID context property is deprecated; use pad.id instead');
warnDeprecated(
"padRemove padID context property is deprecated; use pad.id instead",
);
return this.pad.id;
},
pad: this,
}));
}),
);
await Promise.all(p);
}
@ -626,7 +723,7 @@ class Pad {
}
// build the saved revision object
const savedRevision:MapArrayType<any> = {};
const savedRevision: MapArrayType<any> = {};
savedRevision.revNum = revNum;
savedRevision.savedById = savedById;
savedRevision.label = label || `Revision ${revNum}`;
@ -647,7 +744,7 @@ class Pad {
*/
async check() {
assert(this.id != null);
assert.equal(typeof this.id, 'string');
assert.equal(typeof this.id, "string");
const head = this.getHeadRevisionNumber();
assert(head != null);
@ -672,10 +769,10 @@ class Pad {
const savedRevisionsIds = new Set();
for (const savedRev of savedRevisions) {
assert(savedRev != null);
assert.equal(typeof savedRev, 'object');
assert.equal(typeof savedRev, "object");
assert(savedRevisionsList.includes(savedRev.revNum));
assert(savedRev.id != null);
assert.equal(typeof savedRev.id, 'string');
assert.equal(typeof savedRev.id, "string");
assert(!savedRevisionsIds.has(savedRev.id));
savedRevisionsIds.add(savedRev.id);
}
@ -686,7 +783,7 @@ class Pad {
const authorIds = new Set();
pool.eachAttrib((k, v) => {
if (k === 'author' && v) authorIds.add(v);
if (k === "author" && v) authorIds.add(v);
});
const revs = Stream.range(0, head + 1)
.map(async (r: number) => {
@ -700,40 +797,51 @@ class Pad {
isKeyRev,
isKeyRev ? this._getKeyRevisionAText(r) : null,
]);
} catch (err:any) {
} catch (err: any) {
err.message = `(pad ${this.id} revision ${r}) ${err.message}`;
throw err;
}
})
.batch(100).buffer(99);
let atext = Changeset.makeAText('\n');
for await (const [r, changeset, authorId, timestamp, isKeyRev, keyAText] of revs) {
.batch(100)
.buffer(99);
let atext = Changeset.makeAText("\n");
for await (const [
r,
changeset,
authorId,
timestamp,
isKeyRev,
keyAText,
] of revs) {
try {
assert(authorId != null);
assert.equal(typeof authorId, 'string');
assert.equal(typeof authorId, "string");
if (authorId) authorIds.add(authorId);
assert(timestamp != null);
assert.equal(typeof timestamp, 'number');
assert.equal(typeof timestamp, "number");
assert(timestamp > 0);
assert(changeset != null);
assert.equal(typeof changeset, 'string');
assert.equal(typeof changeset, "string");
Changeset.checkRep(changeset);
const unpacked = Changeset.unpack(changeset);
let text = atext.text;
for (const op of Changeset.deserializeOps(unpacked.ops)) {
if (['=', '-'].includes(op.opcode)) {
if (["=", "-"].includes(op.opcode)) {
assert(text.length >= op.chars);
const consumed = text.slice(0, op.chars);
const nlines = (consumed.match(/\n/g) || []).length;
assert.equal(op.lines, nlines);
if (op.lines > 0) assert(consumed.endsWith('\n'));
if (op.lines > 0) assert(consumed.endsWith("\n"));
text = text.slice(op.chars);
}
assert.equal(op.attribs, AttributeMap.fromString(op.attribs, pool).toString());
assert.equal(
op.attribs,
AttributeMap.fromString(op.attribs, pool).toString(),
);
}
atext = Changeset.applyToAText(changeset, atext, pool);
if (isKeyRev) assert.deepEqual(keyAText, atext);
} catch (err:any) {
} catch (err: any) {
err.message = `(pad ${this.id} revision ${r}) ${err.message}`;
throw err;
}
@ -751,15 +859,16 @@ class Pad {
const msg = await this.getChatMessage(c);
assert(msg != null);
assert(msg instanceof ChatMessage);
} catch (err:any) {
} catch (err: any) {
err.message = `(pad ${this.id} chat message ${c}) ${err.message}`;
throw err;
}
})
.batch(100).buffer(99);
.batch(100)
.buffer(99);
for (const p of chats) await p;
await hooks.aCallAll('padCheck', {pad: this});
await hooks.aCallAll("padCheck", { pad: this });
}
}
exports.Pad = Pad;

View file

@ -1,4 +1,3 @@
'use strict';
/**
* The Pad Manager is a Factory for pad Objects
*/
@ -19,13 +18,13 @@
* limitations under the License.
*/
import {MapArrayType} from "../types/MapType";
import {PadType} from "../types/PadType";
import type { MapArrayType } from "../types/MapType";
import type { PadType } from "../types/PadType";
const CustomError = require('../utils/customError');
const Pad = require('../db/Pad');
const db = require('./DB');
const settings = require('../utils/Settings');
const CustomError = require("../utils/customError");
const Pad = require("../db/Pad");
const db = require("./DB");
const settings = require("../utils/Settings");
/**
* A cache of all loaded Pads.
@ -38,13 +37,11 @@ const settings = require('../utils/Settings');
* If this is needed in other places, it would be wise to make this a prototype
* that's defined somewhere more sensible.
*/
const globalPads:MapArrayType<any> = {
get(name: string)
{
const globalPads: MapArrayType<any> = {
get(name: string) {
return this[`:${name}`];
},
set(name: string, value: any)
{
set(name: string, value: any) {
this[`:${name}`] = value;
},
remove(name: string) {
@ -57,7 +54,7 @@ const globalPads:MapArrayType<any> = {
*
* 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 _list: Set<string>;
private _loaded: Promise<void> | null;
@ -74,9 +71,9 @@ const padList = new class {
async getPads() {
if (!this._loaded) {
this._loaded = (async () => {
const dbData = await db.findKeys('pad:*', '*:*:*');
const dbData = await db.findKeys("pad:*", "*:*:*");
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;
@ -95,7 +92,7 @@ const padList = new class {
this._list.delete(name);
this._cachedList = null;
}
}();
})();
// initialises the all-knowing data structure
@ -106,22 +103,26 @@ const padList = new class {
* @param {string} [authorId] - Optional author ID of the user that initiated the pad creation (if
* applicable).
*/
exports.getPad = async (id: string, text?: string|null, authorId:string|null = ''):Promise<PadType> => {
exports.getPad = async (
id: string,
text?: string | null,
authorId: string | null = "",
): Promise<PadType> => {
// check if this is a valid padId
if (!exports.isValidPadId(id)) {
throw new CustomError(`${id} is not a valid padId`, 'apierror');
throw new CustomError(`${id} is not a valid padId`, "apierror");
}
// check if this is a valid text
if (text != null) {
// check if text is a string
if (typeof text !== 'string') {
throw new CustomError('text is not a string', 'apierror');
if (typeof text !== "string") {
throw new CustomError("text is not a string", "apierror");
}
// check if text is less than 100k chars
if (text.length > 100000) {
throw new CustomError('text must be less than 100k chars', 'apierror');
throw new CustomError("text must be less than 100k chars", "apierror");
}
}
@ -146,17 +147,14 @@ exports.getPad = async (id: string, text?: string|null, authorId:string|null = '
exports.listAllPads = async () => {
const padIDs = await padList.getPads();
return {padIDs};
return { padIDs };
};
// checks if a pad exists
exports.doesPadExist = async (padId: string) => {
const value = await db.get(`pad:${padId}`);
return (value != null && value.atext);
return value != null && value.atext;
};
// alias for backwards compatibility
@ -167,8 +165,8 @@ exports.doesPadExists = exports.doesPadExist;
* time, and allow us to "play back" these changes so legacy padIds can be found.
*/
const padIdTransforms = [
[/\s+/g, '_'],
[/:+/g, '_'],
[/\s+/g, "_"],
[/:+/g, "_"],
];
// returns a sanitized padId, respecting legacy pad id formats
@ -192,7 +190,8 @@ exports.sanitizePadId = async (padId: string) => {
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.

View file

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

View file

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

View file

@ -1,4 +1,3 @@
'use strict';
/**
* The Session Manager provides functions to manage session in the database,
* it only provides session management for sessions created by the API
@ -20,12 +19,12 @@
* limitations under the License.
*/
const CustomError = require('../utils/customError');
const promises = require('../utils/promises');
const randomString = require('../utils/randomstring');
const db = require('./DB');
const groupManager = require('./GroupManager');
const authorManager = require('./AuthorManager');
const CustomError = require("../utils/customError");
const promises = require("../utils/promises");
const randomString = require("../utils/randomstring");
const db = require("./DB");
const groupManager = require("./GroupManager");
const authorManager = require("./AuthorManager");
/**
* Finds the author ID for a session with matching ID and group.
@ -36,7 +35,7 @@ const authorManager = require('./AuthorManager');
* sessionCookie, and is bound to a group with the given ID, then this returns the author ID
* bound to the session. Otherwise, returns undefined.
*/
exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
exports.findAuthorID = async (groupID: string, sessionCookie: string) => {
if (!sessionCookie) return undefined;
/*
* Sometimes, RFC 6265-compliant web servers may send back a cookie whose
@ -61,13 +60,15 @@ exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
* Fixes #3819.
* Also, see #3820.
*/
const sessionIDs = sessionCookie.replace(/^"|"$/g, '').split(',');
const sessionIDs = sessionCookie.replace(/^"|"$/g, "").split(",");
const sessionInfoPromises = sessionIDs.map(async (id) => {
try {
return await exports.getSessionInfo(id);
} catch (err:any) {
if (err.message === 'sessionID does not exist') {
console.debug(`SessionManager getAuthorID: no session exists with ID ${id}`);
} catch (err: any) {
if (err.message === "sessionID does not exist") {
console.debug(
`SessionManager getAuthorID: no session exists with ID ${id}`,
);
} else {
throw err;
}
@ -75,11 +76,16 @@ exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
return undefined;
});
const now = Math.floor(Date.now() / 1000);
const isMatch = (si: {
const isMatch = (
si: {
groupID: string;
validUntil: number;
}|null) => (si != null && si.groupID === groupID && now < si.validUntil);
const sessionInfo = await promises.firstSatisfies(sessionInfoPromises, isMatch);
} | null,
) => si != null && si.groupID === groupID && now < si.validUntil;
const sessionInfo = await promises.firstSatisfies(
sessionInfoPromises,
isMatch,
);
if (sessionInfo == null) return undefined;
return sessionInfo.authorID;
};
@ -92,7 +98,7 @@ exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
exports.doesSessionExist = async (sessionID: string) => {
// check if the database entry of this session exists
const session = await db.get(`session:${sessionID}`);
return (session != null);
return session != null;
};
/**
@ -102,60 +108,64 @@ exports.doesSessionExist = async (sessionID: string) => {
* @param {Number} validUntil The unix timestamp when the session should expire
* @return {Promise<{sessionID: string}>} the id of the new session
*/
exports.createSession = async (groupID: string, authorID: string, validUntil: number) => {
exports.createSession = async (
groupID: string,
authorID: string,
validUntil: number,
) => {
// check if the group exists
const groupExists = await groupManager.doesGroupExist(groupID);
if (!groupExists) {
throw new CustomError('groupID does not exist', 'apierror');
throw new CustomError("groupID does not exist", "apierror");
}
// check if the author exists
const authorExists = await authorManager.doesAuthorExist(authorID);
if (!authorExists) {
throw new CustomError('authorID does not exist', 'apierror');
throw new CustomError("authorID does not exist", "apierror");
}
// try to parse validUntil if it's not a number
if (typeof validUntil !== 'number') {
validUntil = parseInt(validUntil);
if (typeof validUntil !== "number") {
validUntil = Number.parseInt(validUntil);
}
// check it's a valid number
if (isNaN(validUntil)) {
throw new CustomError('validUntil is not a number', 'apierror');
throw new CustomError("validUntil is not a number", "apierror");
}
// ensure this is not a negative number
if (validUntil < 0) {
throw new CustomError('validUntil is a negative number', 'apierror');
throw new CustomError("validUntil is a negative number", "apierror");
}
// ensure this is not a float value
if (!isInt(validUntil)) {
throw new CustomError('validUntil is a float value', 'apierror');
throw new CustomError("validUntil is a float value", "apierror");
}
// check if validUntil is in the future
if (validUntil < Math.floor(Date.now() / 1000)) {
throw new CustomError('validUntil is in the past', 'apierror');
throw new CustomError("validUntil is in the past", "apierror");
}
// generate sessionID
const sessionID = `s.${randomString(16)}`;
// 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
// so that the state is consistent.
await Promise.all([
// UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object
// property, and writes the result.
db.setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], 1),
db.setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], 1),
db.setSub(`group2sessions:${groupID}`, ["sessionIDs", sessionID], 1),
db.setSub(`author2sessions:${authorID}`, ["sessionIDs", sessionID], 1),
]);
return {sessionID};
return { sessionID };
};
/**
@ -163,13 +173,13 @@ exports.createSession = async (groupID: string, authorID: string, validUntil: nu
* @param {String} sessionID The id of the session
* @return {Promise<Object>} the sessioninfos
*/
exports.getSessionInfo = async (sessionID:string) => {
exports.getSessionInfo = async (sessionID: string) => {
// check if the database entry of this session exists
const session = await db.get(`session:${sessionID}`);
if (session == null) {
// session does not exist
throw new CustomError('sessionID does not exist', 'apierror');
throw new CustomError("sessionID does not exist", "apierror");
}
// everything is fine, return the sessioninfos
@ -181,11 +191,11 @@ exports.getSessionInfo = async (sessionID:string) => {
* @param {String} sessionID The id of the session
* @return {Promise<void>} Resolves when the session is deleted
*/
exports.deleteSession = async (sessionID:string) => {
exports.deleteSession = async (sessionID: string) => {
// ensure that the session exists
const session = await db.get(`session:${sessionID}`);
if (session == null) {
throw new CustomError('sessionID does not exist', 'apierror');
throw new CustomError("sessionID does not exist", "apierror");
}
// everything is fine, use the sessioninfos
@ -196,8 +206,16 @@ exports.deleteSession = async (sessionID:string) => {
// UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object
// property, and writes the result. Setting a property to `undefined` deletes that property
// (JSON.stringify() ignores such properties).
db.setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], undefined),
db.setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], undefined),
db.setSub(
`group2sessions:${groupID}`,
["sessionIDs", sessionID],
undefined,
),
db.setSub(
`author2sessions:${authorID}`,
["sessionIDs", sessionID],
undefined,
),
]);
// Delete the session record after updating group2sessions and author2sessions so that the state
@ -214,7 +232,7 @@ exports.listSessionsOfGroup = async (groupID: string) => {
// check that the group exists
const exists = await groupManager.doesGroupExist(groupID);
if (!exists) {
throw new CustomError('groupID does not exist', 'apierror');
throw new CustomError("groupID does not exist", "apierror");
}
const sessions = await listSessionsWithDBKey(`group2sessions:${groupID}`);
@ -230,7 +248,7 @@ exports.listSessionsOfAuthor = async (authorID: string) => {
// check that the author exists
const exists = await authorManager.doesAuthorExist(authorID);
if (!exists) {
throw new CustomError('authorID does not exist', 'apierror');
throw new CustomError("authorID does not exist", "apierror");
}
return await listSessionsWithDBKey(`author2sessions:${authorID}`);
@ -252,8 +270,8 @@ const listSessionsWithDBKey = async (dbkey: string) => {
for (const sessionID of Object.keys(sessions || {})) {
try {
sessions[sessionID] = await exports.getSessionInfo(sessionID);
} catch (err:any) {
if (err.name === 'apierror') {
} catch (err: any) {
if (err.name === "apierror") {
console.warn(`Found bad session ${sessionID} in ${dbkey}`);
sessions[sessionID] = null;
} else {
@ -265,11 +283,11 @@ const listSessionsWithDBKey = async (dbkey: string) => {
return sessions;
};
/**
* checks if a number is an int
* @param {number|string} value
* @return {boolean} If the value is an integer
*/
// @ts-ignore
const isInt = (value:number|string): boolean => (parseFloat(value) === parseInt(value)) && !isNaN(value);
const isInt = (value: number | string): boolean =>
Number.parseFloat(value) === Number.parseInt(value) && !isNaN(value);

View file

@ -1,11 +1,9 @@
'use strict';
const DB = require("./DB");
const Store = require("express-session").Store;
const log4js = require("log4js");
const util = require("util");
const DB = require('./DB');
const Store = require('express-session').Store;
const log4js = require('log4js');
const util = require('util');
const logger = log4js.getLogger('SessionStore');
const logger = log4js.getLogger("SessionStore");
class SessionStore extends Store {
/**
@ -31,14 +29,16 @@ class SessionStore extends Store {
}
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) {
const exp = this._expirations.get(sid) || {};
clearTimeout(exp.timeout);
// @ts-ignore
const {cookie: {expires} = {}} = sess || {};
const {
cookie: { expires } = {},
} = sess || {};
if (expires) {
const sessExp = new Date(expires).getTime();
if (updateDbExp) exp.db = sessExp;
@ -47,7 +47,8 @@ class SessionStore extends Store {
if (exp.real <= now) return await this._destroy(sid);
// If reading from the database, update the expiration with the latest value from touch() so
// that touch() appears to write to the database every time even though it doesn't.
if (typeof expires === 'string') sess.cookie.expires = new Date(exp.real).toJSON();
if (typeof expires === "string")
sess.cookie.expires = new Date(exp.real).toJSON();
// Use this._get(), not this._destroy(), to destroy the DB record for the expired session.
// This is done in case multiple Etherpad instances are sharing the same database and users
// are bouncing between the instances. By using this._get(), this instance will query the DB
@ -74,13 +75,13 @@ class SessionStore extends Store {
return await this._updateExpirations(sid, s);
}
async _set(sid: string, sess:any) {
async _set(sid: string, sess: any) {
logger.debug(`SET ${sid}`);
sess = await this._updateExpirations(sid, sess);
if (sess != null) await this._write(sid, sess);
}
async _destroy(sid:string) {
async _destroy(sid: string) {
logger.debug(`DESTROY ${sid}`);
clearTimeout((this._expirations.get(sid) || {}).timeout);
this._expirations.delete(sid);
@ -90,7 +91,7 @@ class SessionStore extends Store {
// Note: express-session might call touch() before it calls set() for the first time. Ideally this
// would behave like set() in that case but it's OK if it doesn't -- express-session will call
// set() soon enough.
async _touch(sid: string, sess:any) {
async _touch(sid: string, sess: any) {
logger.debug(`TOUCH ${sid}`);
sess = await this._updateExpirations(sid, sess, false);
if (sess == null) return; // Already expired.
@ -99,7 +100,11 @@ class SessionStore extends Store {
// database if it didn't already exist, but we have no way of knowing that without querying the
// database. The query overhead is not worth it because set() should be called soon anyway.
if (exp == null) return;
if (exp.db != null && (this._refresh == null || exp.real < exp.db + this._refresh)) return;
if (
exp.db != null &&
(this._refresh == null || exp.real < exp.db + this._refresh)
)
return;
await this._write(sid, sess);
exp.db = new Date(sess.cookie.expires).getTime();
}
@ -107,7 +112,7 @@ class SessionStore extends Store {
// express-session doesn't support Promise-based methods. This is where the callbackified versions
// used by express-session are defined.
for (const m of ['get', 'set', 'destroy', 'touch']) {
for (const m of ["get", "set", "destroy", "touch"]) {
SessionStore.prototype[m] = util.callbackify(SessionStore.prototype[`_${m}`]);
}

View file

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

View file

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

View file

@ -1,4 +1,3 @@
'use strict';
/**
* Handles the export requests
*/
@ -20,15 +19,15 @@
* limitations under the License.
*/
const exporthtml = require('../utils/ExportHtml');
const exporttxt = require('../utils/ExportTxt');
const exportEtherpad = require('../utils/ExportEtherpad');
import fs from 'fs';
const settings = require('../utils/Settings');
import os from 'os';
const hooks = require('../../static/js/pluginfw/hooks');
import util from 'util';
const { checkValidRev } = require('../utils/checkValidRev');
const exporthtml = require("../utils/ExportHtml");
const exporttxt = require("../utils/ExportTxt");
const exportEtherpad = require("../utils/ExportEtherpad");
import fs from "fs";
const settings = require("../utils/Settings");
import os from "os";
const hooks = require("../../static/js/pluginfw/hooks");
import util from "util";
const { checkValidRev } = require("../utils/checkValidRev");
const fsp_writeFile = util.promisify(fs.writeFile);
const fsp_unlink = util.promisify(fs.unlink);
@ -43,12 +42,18 @@ const tempDirectory = os.tmpdir();
* @param {String} readOnlyId the read only id of the pad to export
* @param {String} type the type to export
*/
exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string, type:string) => {
exports.doExport = async (
req: any,
res: any,
padId: string,
readOnlyId: string,
type: string,
) => {
// avoid naming the read-only file as the original pad's id
let fileName = readOnlyId ? readOnlyId : padId;
// allow fileName to be overwritten by a hook, the type type is kept static for security reasons
const hookFileName = await hooks.aCallFirst('exportFileName', padId);
const hookFileName = await hooks.aCallFirst("exportFileName", padId);
// if fileName is set then set it to the padId, note that fileName is returned as an array.
if (hookFileName.length) {
@ -66,29 +71,33 @@ exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string,
// if this is a plain text export, we can do this directly
// We have to over engineer this because tabs are stored as attributes and not plain text
if (type === 'etherpad') {
if (type === "etherpad") {
const pad = await exportEtherpad.getPadRaw(padId, readOnlyId);
res.send(pad);
} else if (type === 'txt') {
} else if (type === "txt") {
const txt = await exporttxt.getPadTXTDocument(padId, req.params.rev);
res.send(txt);
} else {
// render the html document
let html = await exporthtml.getPadHTMLDocument(padId, req.params.rev, readOnlyId);
let html = await exporthtml.getPadHTMLDocument(
padId,
req.params.rev,
readOnlyId,
);
// decide what to do with the html export
// 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
const newHTML = await hooks.aCallFirst('exportHTMLSend', html);
const newHTML = await hooks.aCallFirst("exportHTMLSend", html);
if (newHTML.length) html = newHTML;
res.send(html);
return;
}
// else write the html export to a file
const randNum = Math.floor(Math.random() * 0xFFFFFFFF);
const randNum = Math.floor(Math.random() * 0xffffffff);
const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`;
await fsp_writeFile(srcFile, html);
@ -99,13 +108,20 @@ exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string,
const destFile = `${tempDirectory}/etherpad_export_${randNum}.${type}`;
// Allow plugins to overwrite the convert in export process
const result = await hooks.aCallAll('exportConvert', {srcFile, destFile, req, res});
const result = await hooks.aCallAll("exportConvert", {
srcFile,
destFile,
req,
res,
});
if (result.length > 0) {
// console.log("export handled by plugin", destFile);
} else {
const converter =
settings.soffice != null ? require('../utils/LibreOffice')
: settings.abiword != null ? require('../utils/Abiword')
settings.soffice != null
? require("../utils/LibreOffice")
: settings.abiword != null
? require("../utils/Abiword")
: null;
await converter.convertFile(srcFile, destFile, type);
}
@ -117,7 +133,7 @@ exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string,
await fsp_unlink(srcFile);
// 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));
}

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,63 +1,67 @@
'use strict';
import type { Socket } from "node:net";
import type { MapArrayType } from "../types/MapType";
import {Socket} from "node:net";
import type {MapArrayType} from "../types/MapType";
import _ from 'underscore';
import events from "events";
import fs from "fs";
// @ts-ignore
import cookieParser from 'cookie-parser';
import events from 'events';
import express from 'express';
import cookieParser from "cookie-parser";
import express from "express";
// @ts-ignore
import expressSession from 'express-session';
import fs from 'fs';
const hooks = require('../../static/js/pluginfw/hooks');
import log4js from 'log4js';
const SessionStore = require('../db/SessionStore');
const settings = require('../utils/Settings');
const stats = require('../stats')
import util from 'util';
const webaccess = require('./express/webaccess');
import expressSession from "express-session";
import _ from "underscore";
const hooks = require("../../static/js/pluginfw/hooks");
import log4js from "log4js";
const SessionStore = require("../db/SessionStore");
const settings = require("../utils/Settings");
const stats = require("../stats");
import util from "util";
const webaccess = require("./express/webaccess");
import SecretRotator from '../security/SecretRotator';
import SecretRotator from "../security/SecretRotator";
let secretRotator: SecretRotator|null = null;
const logger = log4js.getLogger('http');
let serverName:string;
let sessionStore: { shutdown: () => void; } | null;
const sockets:Set<Socket> = new Set();
let secretRotator: SecretRotator | null = null;
const logger = log4js.getLogger("http");
let serverName: string;
let sessionStore: { shutdown: () => void } | null;
const sockets: Set<Socket> = new Set();
const socketsEvents = new events.EventEmitter();
const startTime = stats.settableGauge('httpStartTime');
const startTime = stats.settableGauge("httpStartTime");
exports.server = null;
const closeServer = async () => {
if (exports.server != null) {
logger.info('Closing HTTP server...');
logger.info("Closing HTTP server...");
// Call exports.server.close() to reject new connections but don't await just yet because the
// Promise won't resolve until all preexisting connections are closed.
const p = util.promisify(exports.server.close.bind(exports.server))();
await hooks.aCallAll('expressCloseServer');
await hooks.aCallAll("expressCloseServer");
// Give existing connections some time to close on their own before forcibly terminating. The
// time should be long enough to avoid interrupting most preexisting transmissions but short
// enough to avoid a noticeable outage.
const timeout = setTimeout(async () => {
logger.info(`Forcibly terminating remaining ${sockets.size} HTTP connections...`);
for (const socket of sockets) socket.destroy(new Error('HTTP server is closing'));
logger.info(
`Forcibly terminating remaining ${sockets.size} HTTP connections...`,
);
for (const socket of sockets)
socket.destroy(new Error("HTTP server is closing"));
}, 5000);
let lastLogged = 0;
while (sockets.size > 0 && !settings.enableAdminUITests) {
if (Date.now() - lastLogged > 1000) { // Rate limit to avoid filling logs.
logger.info(`Waiting for ${sockets.size} HTTP clients to disconnect...`);
if (Date.now() - lastLogged > 1000) {
// Rate limit to avoid filling logs.
logger.info(
`Waiting for ${sockets.size} HTTP clients to disconnect...`,
);
lastLogged = Date.now();
}
await events.once(socketsEvents, 'updated');
await events.once(socketsEvents, "updated");
}
await p;
clearTimeout(timeout);
exports.server = null;
startTime.setValue(0);
logger.info('HTTP server closed');
logger.info("HTTP server closed");
}
if (sessionStore) sessionStore.shutdown();
sessionStore = null;
@ -66,34 +70,46 @@ const closeServer = 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)`;
console.log(`Your Etherpad version is ${settings.getEpVersion()} (${settings.getGitCommit()})`);
console.log(
`Your Etherpad version is ${settings.getEpVersion()} (${settings.getGitCommit()})`,
);
await exports.restartServer();
if (settings.ip === '') {
if (settings.ip === "") {
// using Unix socket for connectivity
console.log(`You can access your Etherpad instance using the Unix socket at ${settings.port}`);
console.log(
`You can access your Etherpad instance using the Unix socket at ${settings.port}`,
);
} else {
console.log(`You can access your Etherpad instance at http://${settings.ip}:${settings.port}/`);
console.log(
`You can access your Etherpad instance at http://${settings.ip}:${settings.port}/`,
);
}
if (!_.isEmpty(settings.users)) {
console.log(`The plugin admin page is at http://${settings.ip}:${settings.port}/admin/plugins`);
console.log(
`The plugin admin page is at http://${settings.ip}:${settings.port}/admin/plugins`,
);
} else {
console.warn('Admin username and password not set in settings.json. ' +
'To access admin please uncomment and edit "users" in settings.json');
console.warn(
"Admin username and password not set in settings.json. " +
'To access admin please uncomment and edit "users" in settings.json',
);
}
const env = process.env.NODE_ENV || 'development';
const env = process.env.NODE_ENV || "development";
if (env !== 'production') {
console.warn('Etherpad is running in Development mode. This mode is slower for users and ' +
'less secure than production mode. You should set the NODE_ENV environment ' +
'variable to production by using: export NODE_ENV=production');
if (env !== "production") {
console.warn(
"Etherpad is running in Development mode. This mode is slower for users and " +
"less secure than production mode. You should set the NODE_ENV environment " +
"variable to production by using: export NODE_ENV=production",
);
}
};
@ -103,9 +119,11 @@ exports.restartServer = async () => {
const app = express(); // New syntax for express v3
if (settings.ssl) {
console.log('SSL -- enabled');
console.log("SSL -- enabled");
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> = {
key: fs.readFileSync(settings.ssl.key),
@ -120,10 +138,10 @@ exports.restartServer = async () => {
}
}
const https = require('https');
const https = require("https");
exports.server = https.createServer(options, app);
} else {
const http = require('http');
const http = require("http");
exports.server = http.createServer(app);
}
@ -131,12 +149,15 @@ exports.restartServer = async () => {
// res.header("X-Frame-Options", "deny"); // breaks embedded pads
if (settings.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
// 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
// loading local resources, but it will drop them when loading foreign resources.
@ -145,11 +166,11 @@ exports.restartServer = async () => {
// marked with <meta name="referrer" content="no-referrer">
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
// https://github.com/ether/etherpad-lite/pull/3636
res.header('Referrer-Policy', 'same-origin');
res.header("Referrer-Policy", "same-origin");
// send git version in the Server response header if exposeVersion is true.
if (settings.exposeVersion) {
res.header('Server', serverName);
res.header("Server", serverName);
}
next();
@ -162,14 +183,17 @@ exports.restartServer = async () => {
*
* Source: https://expressjs.com/en/guide/behind-proxies.html
*/
app.enable('trust proxy');
app.enable("trust proxy");
}
// Measure response time
app.use((req, res, next) => {
const stopWatch = stats.timer('httpRequests').start();
const stopWatch = stats.timer("httpRequests").start();
const sendFn = res.send.bind(res);
res.send = (...args) => { stopWatch.end(); return sendFn(...args); };
res.send = (...args) => {
stopWatch.end();
return sendFn(...args);
};
next();
});
@ -177,22 +201,28 @@ exports.restartServer = async () => {
// starts listening to requests as reported in issue #158. Not installing the log4js connect
// logger when the log level has a higher severity than INFO since it would not log at that level
// anyway.
if (!(settings.loglevel === 'WARN' && settings.loglevel === 'ERROR')) {
app.use(log4js.connectLogger(logger, {
if (!(settings.loglevel === "WARN" && settings.loglevel === "ERROR")) {
app.use(
log4js.connectLogger(logger, {
level: log4js.levels.DEBUG.levelStr,
format: ':status, :method :url',
}));
format: ":status, :method :url",
}),
);
}
const {keyRotationInterval, sessionLifetime} = settings.cookie;
const { keyRotationInterval, sessionLifetime } = settings.cookie;
let secret = settings.sessionKey;
if (keyRotationInterval && sessionLifetime) {
secretRotator = new SecretRotator(
'expressSessionSecrets', keyRotationInterval, sessionLifetime, settings.sessionKey);
"expressSessionSecrets",
keyRotationInterval,
sessionLifetime,
settings.sessionKey,
);
await secretRotator.start();
secret = secretRotator.secrets;
}
if (!secret) throw new Error('missing cookie signing secret');
if (!secret) throw new Error("missing cookie signing secret");
app.use(cookieParser(secret, {}));
@ -206,7 +236,7 @@ exports.restartServer = async () => {
saveUninitialized: false,
// Set the cookie name to a javascript identifier compatible string. Makes code handling it
// cleaner :)
name: 'express_sid',
name: "express_sid",
cookie: {
maxAge: sessionLifetime || null, // Convert 0 to null.
sameSite: settings.cookie.sameSite,
@ -227,35 +257,38 @@ exports.restartServer = async () => {
// https at the same time.
//
// 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
// middleware. This allows plugins to avoid creating an express-session record in the database
// when it is not needed (e.g., public static content).
await hooks.aCallAll('expressPreSession', {app});
await hooks.aCallAll("expressPreSession", { app });
app.use(exports.sessionMiddleware);
app.use(webaccess.checkAccess);
await Promise.all([
hooks.aCallAll('expressConfigure', {app}),
hooks.aCallAll('expressCreateServer', {app, server: exports.server}),
hooks.aCallAll("expressConfigure", { app }),
hooks.aCallAll("expressCreateServer", { app, server: exports.server }),
]);
exports.server.on('connection', (socket:Socket) => {
exports.server.on("connection", (socket: Socket) => {
sockets.add(socket);
socketsEvents.emit('updated');
socket.on('close', () => {
socketsEvents.emit("updated");
socket.on("close", () => {
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)(
settings.port,
settings.ip,
);
startTime.setValue(Date.now());
logger.info('HTTP server listening for connections');
logger.info("HTTP server listening for connections");
};
exports.shutdown = async (hookName:string, context: any) => {
exports.shutdown = async (hookName: string, context: any) => {
await closeServer();
};

View file

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

View file

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

View file

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

View file

@ -1,20 +1,18 @@
'use strict';
const log4js = require("log4js");
const clientLogger = log4js.getLogger("client");
const { Formidable } = require("formidable");
const apiHandler = require("../../handler/APIHandler");
const util = require("util");
const log4js = require('log4js');
const clientLogger = log4js.getLogger('client');
const {Formidable} = require('formidable');
const apiHandler = require('../../handler/APIHandler');
const util = require('util');
exports.expressPreSession = async (hookName:string, {app}:any) => {
exports.expressPreSession = async (hookName: string, { app }: any) => {
// The Etherpad client side sends information about how a disconnect happened
app.post('/ep/pad/connection-diagnostic-info', async (req:any, res:any) => {
const [fields, files] = await (new Formidable({})).parse(req);
app.post("/ep/pad/connection-diagnostic-info", async (req: any, res: any) => {
const [fields, files] = await new Formidable({}).parse(req);
clientLogger.info(`DIAGNOSTIC-INFO: ${fields.diagnosticInfo}`);
res.end('OK');
res.end("OK");
});
const parseJserrorForm = async (req:any) => {
const parseJserrorForm = async (req: any) => {
const form = new Formidable({
maxFileSize: 1, // Files are not expected. Not sure if 0 means unlimited, so 1 is used.
});
@ -23,22 +21,25 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
};
// 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 () => {
const data = JSON.parse(await parseJserrorForm(req));
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.
options = Object.assign({}, options, {depth: Infinity, colors: true});
options = Object.assign({}, options, {
depth: Number.POSITIVE_INFINITY,
colors: true,
});
return util.inspect(data, options);
},
});
res.end('OK');
res.end("OK");
})().catch((err) => next(err || new Error(err)));
});
// Provide a possibility to query the latest available API version
app.get('/api', (req:any, res:any) => {
res.json({currentVersion: apiHandler.latestApiVersion});
app.get("/api", (req: any, res: any) => {
res.json({ currentVersion: apiHandler.latestApiVersion });
});
};

View file

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

View file

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

View file

@ -1,8 +1,10 @@
'use strict';
import {OpenAPIOperations, OpenAPISuccessResponse, SwaggerUIResource} from "../../types/SwaggerUIResource";
import {MapArrayType} from "../../types/MapType";
import {ErrorCaused} from "../../types/ErrorCaused";
import type { ErrorCaused } from "../../types/ErrorCaused";
import type { MapArrayType } from "../../types/MapType";
import type {
OpenAPIOperations,
OpenAPISuccessResponse,
SwaggerUIResource,
} from "../../types/SwaggerUIResource";
/**
* node/hooks/express/openapi.js
@ -18,260 +20,282 @@ import {ErrorCaused} from "../../types/ErrorCaused";
* - /rest/{version}/openapi.json
*/
const OpenAPIBackend = require('openapi-backend').default;
const IncomingForm = require('formidable').IncomingForm;
const cloneDeep = require('lodash.clonedeep');
const createHTTPError = require('http-errors');
const OpenAPIBackend = require("openapi-backend").default;
const IncomingForm = require("formidable").IncomingForm;
const cloneDeep = require("lodash.clonedeep");
const createHTTPError = require("http-errors");
const apiHandler = require('../../handler/APIHandler');
const settings = require('../../utils/Settings');
const apiHandler = require("../../handler/APIHandler");
const settings = require("../../utils/Settings");
const log4js = require('log4js');
const logger = log4js.getLogger('API');
const log4js = require("log4js");
const logger = log4js.getLogger("API");
// https://github.com/OAI/OpenAPI-Specification/tree/master/schemas/v3.0
const OPENAPI_VERSION = '3.0.2'; // Swagger/OAS version
const OPENAPI_VERSION = "3.0.2"; // Swagger/OAS version
const info = {
title: 'Etherpad API',
title: "Etherpad API",
description:
'Etherpad is a real-time collaborative editor scalable to thousands of simultaneous ' +
'real time users. It provides full data export capabilities, and runs on your server, ' +
'under your control.',
termsOfService: 'https://etherpad.org/',
"Etherpad is a real-time collaborative editor scalable to thousands of simultaneous " +
"real time users. It provides full data export capabilities, and runs on your server, " +
"under your control.",
termsOfService: "https://etherpad.org/",
contact: {
name: 'The Etherpad Foundation',
url: 'https://etherpad.org/',
email: 'support@example.com',
name: "The Etherpad Foundation",
url: "https://etherpad.org/",
email: "support@example.com",
},
license: {
name: 'Apache 2.0',
url: 'https://www.apache.org/licenses/LICENSE-2.0.html',
name: "Apache 2.0",
url: "https://www.apache.org/licenses/LICENSE-2.0.html",
},
version: apiHandler.latestApiVersion,
};
const APIPathStyle = {
FLAT: 'api', // flat paths e.g. /api/createGroup
REST: 'rest', // restful paths e.g. /rest/group/create
FLAT: "api", // flat paths e.g. /api/createGroup
REST: "rest", // restful paths e.g. /rest/group/create
};
// API resources - describe your API endpoints here
const resources:SwaggerUIResource = {
const resources: SwaggerUIResource = {
// Group
group: {
create: {
operationId: 'createGroup',
summary: 'creates a new group',
responseSchema: {groupID: {type: 'string'}},
operationId: "createGroup",
summary: "creates a new group",
responseSchema: { groupID: { type: "string" } },
},
createIfNotExistsFor: {
operationId: 'createGroupIfNotExistsFor',
summary: 'this functions helps you to map your application group ids to Etherpad group ids',
responseSchema: {groupID: {type: 'string'}},
operationId: "createGroupIfNotExistsFor",
summary:
"this functions helps you to map your application group ids to Etherpad group ids",
responseSchema: { groupID: { type: "string" } },
},
delete: {
operationId: 'deleteGroup',
summary: 'deletes a group',
operationId: "deleteGroup",
summary: "deletes a group",
},
listPads: {
operationId: 'listPads',
summary: 'returns all pads of this group',
responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},
operationId: "listPads",
summary: "returns all pads of this group",
responseSchema: { padIDs: { type: "array", items: { type: "string" } } },
},
createPad: {
operationId: 'createGroupPad',
summary: 'creates a new pad in this group',
operationId: "createGroupPad",
summary: "creates a new pad in this group",
},
listSessions: {
operationId: 'listSessionsOfGroup',
summary: '',
operationId: "listSessionsOfGroup",
summary: "",
responseSchema: {
sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}},
sessions: {
type: "array",
items: { $ref: "#/components/schemas/SessionInfo" },
},
},
},
list: {
operationId: 'listAllGroups',
summary: '',
responseSchema: {groupIDs: {type: 'array', items: {type: 'string'}}},
operationId: "listAllGroups",
summary: "",
responseSchema: {
groupIDs: { type: "array", items: { type: "string" } },
},
},
},
// Author
author: {
create: {
operationId: 'createAuthor',
summary: 'creates a new author',
responseSchema: {authorID: {type: 'string'}},
operationId: "createAuthor",
summary: "creates a new author",
responseSchema: { authorID: { type: "string" } },
},
createIfNotExistsFor: {
operationId: 'createAuthorIfNotExistsFor',
summary: 'this functions helps you to map your application author ids to Etherpad author ids',
responseSchema: {authorID: {type: 'string'}},
operationId: "createAuthorIfNotExistsFor",
summary:
"this functions helps you to map your application author ids to Etherpad author ids",
responseSchema: { authorID: { type: "string" } },
},
listPads: {
operationId: 'listPadsOfAuthor',
summary: 'returns an array of all pads this author contributed to',
responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},
operationId: "listPadsOfAuthor",
summary: "returns an array of all pads this author contributed to",
responseSchema: { padIDs: { type: "array", items: { type: "string" } } },
},
listSessions: {
operationId: 'listSessionsOfAuthor',
summary: 'returns all sessions of an author',
operationId: "listSessionsOfAuthor",
summary: "returns all sessions of an author",
responseSchema: {
sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}},
sessions: {
type: "array",
items: { $ref: "#/components/schemas/SessionInfo" },
},
},
},
// We need an operation that return a UserInfo so it can be picked up by the codegen :(
getName: {
operationId: 'getAuthorName',
summary: 'Returns the Author Name of the author',
responseSchema: {info: {$ref: '#/components/schemas/UserInfo'}},
operationId: "getAuthorName",
summary: "Returns the Author Name of the author",
responseSchema: { info: { $ref: "#/components/schemas/UserInfo" } },
},
},
// Session
session: {
create: {
operationId: 'createSession',
summary: 'creates a new session. validUntil is an unix timestamp in seconds',
responseSchema: {sessionID: {type: 'string'}},
operationId: "createSession",
summary:
"creates a new session. validUntil is an unix timestamp in seconds",
responseSchema: { sessionID: { type: "string" } },
},
delete: {
operationId: 'deleteSession',
summary: 'deletes a session',
operationId: "deleteSession",
summary: "deletes a session",
},
// We need an operation that returns a SessionInfo so it can be picked up by the codegen :(
info: {
operationId: 'getSessionInfo',
summary: 'returns information about a session',
responseSchema: {info: {$ref: '#/components/schemas/SessionInfo'}},
operationId: "getSessionInfo",
summary: "returns information about a session",
responseSchema: { info: { $ref: "#/components/schemas/SessionInfo" } },
},
},
// Pad
pad: {
listAll: {
operationId: 'listAllPads',
summary: 'list all the pads',
responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},
operationId: "listAllPads",
summary: "list all the pads",
responseSchema: { padIDs: { type: "array", items: { type: "string" } } },
},
createDiffHTML: {
operationId: 'createDiffHTML',
summary: '',
operationId: "createDiffHTML",
summary: "",
responseSchema: {},
},
create: {
operationId: 'createPad',
operationId: "createPad",
description:
'creates a new (non-group) pad. Note that if you need to create a group Pad, ' +
'you should call createGroupPad',
"creates a new (non-group) pad. Note that if you need to create a group Pad, " +
"you should call createGroupPad",
},
getText: {
operationId: 'getText',
summary: 'returns the text of a pad',
responseSchema: {text: {type: 'string'}},
operationId: "getText",
summary: "returns the text of a pad",
responseSchema: { text: { type: "string" } },
},
setText: {
operationId: 'setText',
summary: 'sets the text of a pad',
operationId: "setText",
summary: "sets the text of a pad",
},
getHTML: {
operationId: 'getHTML',
summary: 'returns the text of a pad formatted as HTML',
responseSchema: {html: {type: 'string'}},
operationId: "getHTML",
summary: "returns the text of a pad formatted as HTML",
responseSchema: { html: { type: "string" } },
},
setHTML: {
operationId: 'setHTML',
summary: 'sets the text of a pad with HTML',
operationId: "setHTML",
summary: "sets the text of a pad with HTML",
},
getRevisionsCount: {
operationId: 'getRevisionsCount',
summary: 'returns the number of revisions of this pad',
responseSchema: {revisions: {type: 'integer'}},
operationId: "getRevisionsCount",
summary: "returns the number of revisions of this pad",
responseSchema: { revisions: { type: "integer" } },
},
getLastEdited: {
operationId: 'getLastEdited',
summary: 'returns the timestamp of the last revision of the pad',
responseSchema: {lastEdited: {type: 'integer'}},
operationId: "getLastEdited",
summary: "returns the timestamp of the last revision of the pad",
responseSchema: { lastEdited: { type: "integer" } },
},
delete: {
operationId: 'deletePad',
summary: 'deletes a pad',
operationId: "deletePad",
summary: "deletes a pad",
},
getReadOnlyID: {
operationId: 'getReadOnlyID',
summary: 'returns the read only link of a pad',
responseSchema: {readOnlyID: {type: 'string'}},
operationId: "getReadOnlyID",
summary: "returns the read only link of a pad",
responseSchema: { readOnlyID: { type: "string" } },
},
setPublicStatus: {
operationId: 'setPublicStatus',
summary: 'sets a boolean for the public status of a pad',
operationId: "setPublicStatus",
summary: "sets a boolean for the public status of a pad",
},
getPublicStatus: {
operationId: 'getPublicStatus',
summary: 'return true of false',
responseSchema: {publicStatus: {type: 'boolean'}},
operationId: "getPublicStatus",
summary: "return true of false",
responseSchema: { publicStatus: { type: "boolean" } },
},
authors: {
operationId: 'listAuthorsOfPad',
summary: 'returns an array of authors who contributed to this pad',
responseSchema: {authorIDs: {type: 'array', items: {type: 'string'}}},
operationId: "listAuthorsOfPad",
summary: "returns an array of authors who contributed to this pad",
responseSchema: {
authorIDs: { type: "array", items: { type: "string" } },
},
},
usersCount: {
operationId: 'padUsersCount',
summary: 'returns the number of user that are currently editing this pad',
responseSchema: {padUsersCount: {type: 'integer'}},
operationId: "padUsersCount",
summary: "returns the number of user that are currently editing this pad",
responseSchema: { padUsersCount: { type: "integer" } },
},
users: {
operationId: 'padUsers',
summary: 'returns the list of users that are currently editing this pad',
responseSchema: {padUsers: {type: 'array', items: {$ref: '#/components/schemas/UserInfo'}}},
operationId: "padUsers",
summary: "returns the list of users that are currently editing this pad",
responseSchema: {
padUsers: {
type: "array",
items: { $ref: "#/components/schemas/UserInfo" },
},
},
},
sendClientsMessage: {
operationId: 'sendClientsMessage',
summary: 'sends a custom message of type msg to the pad',
operationId: "sendClientsMessage",
summary: "sends a custom message of type msg to the pad",
},
checkToken: {
operationId: 'checkToken',
summary: 'returns ok when the current api token is valid',
operationId: "checkToken",
summary: "returns ok when the current api token is valid",
},
getChatHistory: {
operationId: 'getChatHistory',
summary: 'returns the chat history',
responseSchema: {messages: {type: 'array', items: {$ref: '#/components/schemas/Message'}}},
operationId: "getChatHistory",
summary: "returns the chat history",
responseSchema: {
messages: {
type: "array",
items: { $ref: "#/components/schemas/Message" },
},
},
},
// We need an operation that returns a Message so it can be picked up by the codegen :(
getChatHead: {
operationId: 'getChatHead',
summary: 'returns the chatHead (chat-message) of the pad',
responseSchema: {chatHead: {$ref: '#/components/schemas/Message'}},
operationId: "getChatHead",
summary: "returns the chatHead (chat-message) of the pad",
responseSchema: { chatHead: { $ref: "#/components/schemas/Message" } },
},
appendChatMessage: {
operationId: 'appendChatMessage',
summary: 'appends a chat message',
operationId: "appendChatMessage",
summary: "appends a chat message",
},
},
};
const defaultResponses = {
Success: {
description: 'ok (code 0)',
description: "ok (code 0)",
content: {
'application/json': {
"application/json": {
schema: {
type: 'object',
type: "object",
properties: {
code: {
type: 'integer',
type: "integer",
example: 0,
},
message: {
type: 'string',
example: 'ok',
type: "string",
example: "ok",
},
data: {
type: 'object',
type: "object",
example: null,
},
},
@ -280,22 +304,22 @@ const defaultResponses = {
},
},
ApiError: {
description: 'generic api error (code 1)',
description: "generic api error (code 1)",
content: {
'application/json': {
"application/json": {
schema: {
type: 'object',
type: "object",
properties: {
code: {
type: 'integer',
type: "integer",
example: 1,
},
message: {
type: 'string',
example: 'error message',
type: "string",
example: "error message",
},
data: {
type: 'object',
type: "object",
example: null,
},
},
@ -304,22 +328,22 @@ const defaultResponses = {
},
},
InternalError: {
description: 'internal api error (code 2)',
description: "internal api error (code 2)",
content: {
'application/json': {
"application/json": {
schema: {
type: 'object',
type: "object",
properties: {
code: {
type: 'integer',
type: "integer",
example: 2,
},
message: {
type: 'string',
example: 'internal error',
type: "string",
example: "internal error",
},
data: {
type: 'object',
type: "object",
example: null,
},
},
@ -328,22 +352,22 @@ const defaultResponses = {
},
},
NotFound: {
description: 'no such function (code 4)',
description: "no such function (code 4)",
content: {
'application/json': {
"application/json": {
schema: {
type: 'object',
type: "object",
properties: {
code: {
type: 'integer',
type: "integer",
example: 3,
},
message: {
type: 'string',
example: 'no such function',
type: "string",
example: "no such function",
},
data: {
type: 'object',
type: "object",
example: null,
},
},
@ -352,22 +376,22 @@ const defaultResponses = {
},
},
Unauthorized: {
description: 'no or wrong API key (code 4)',
description: "no or wrong API key (code 4)",
content: {
'application/json': {
"application/json": {
schema: {
type: 'object',
type: "object",
properties: {
code: {
type: 'integer',
type: "integer",
example: 4,
},
message: {
type: 'string',
example: 'no or wrong API key',
type: "string",
example: "no or wrong API key",
},
data: {
type: 'object',
type: "object",
example: null,
},
},
@ -377,18 +401,18 @@ const defaultResponses = {
},
};
const defaultResponseRefs:OpenAPISuccessResponse = {
const defaultResponseRefs: OpenAPISuccessResponse = {
200: {
$ref: '#/components/responses/Success',
$ref: "#/components/responses/Success",
},
400: {
$ref: '#/components/responses/ApiError',
$ref: "#/components/responses/ApiError",
},
401: {
$ref: '#/components/responses/Unauthorized',
$ref: "#/components/responses/Unauthorized",
},
500: {
$ref: '#/components/responses/InternalError',
$ref: "#/components/responses/InternalError",
},
};
@ -396,14 +420,14 @@ const defaultResponseRefs:OpenAPISuccessResponse = {
const operations: OpenAPIOperations = {};
for (const [resource, actions] of Object.entries(resources)) {
for (const [action, spec] of Object.entries(actions)) {
const {operationId,responseSchema, ...operation} = spec;
const { operationId, responseSchema, ...operation } = spec;
// add response objects
const responses:OpenAPISuccessResponse = {...defaultResponseRefs};
const responses: OpenAPISuccessResponse = { ...defaultResponseRefs };
if (responseSchema) {
responses[200] = cloneDeep(defaultResponses.Success);
responses[200].content!['application/json'].schema.properties.data = {
type: 'object',
responses[200].content!["application/json"].schema.properties.data = {
type: "object",
properties: responseSchema,
};
}
@ -419,7 +443,10 @@ for (const [resource, actions] of Object.entries(resources)) {
}
}
const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT) => {
const generateDefinitionForVersion = (
version: string,
style = APIPathStyle.FLAT,
) => {
const definition = {
openapi: OPENAPI_VERSION,
info,
@ -428,53 +455,53 @@ const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT)
parameters: {},
schemas: {
SessionInfo: {
type: 'object',
type: "object",
properties: {
id: {
type: 'string',
type: "string",
},
authorID: {
type: 'string',
type: "string",
},
groupID: {
type: 'string',
type: "string",
},
validUntil: {
type: 'integer',
type: "integer",
},
},
},
UserInfo: {
type: 'object',
type: "object",
properties: {
id: {
type: 'string',
type: "string",
},
colorId: {
type: 'string',
type: "string",
},
name: {
type: 'string',
type: "string",
},
timestamp: {
type: 'integer',
type: "integer",
},
},
},
Message: {
type: 'object',
type: "object",
properties: {
text: {
type: 'string',
type: "string",
},
userId: {
type: 'string',
type: "string",
},
userName: {
type: 'string',
type: "string",
},
time: {
type: 'integer',
type: "integer",
},
},
},
@ -487,27 +514,27 @@ const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT)
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: settings.sso.issuer+"/oidc/auth",
tokenUrl: settings.sso.issuer+"/oidc/token",
authorizationUrl: settings.sso.issuer + "/oidc/auth",
tokenUrl: settings.sso.issuer + "/oidc/token",
scopes: {
openid: "openid",
profile: "profile",
email: "email",
admin: "admin"
}
}
admin: "admin",
},
},
},
},
security: [{openid: []}],
},
},
security: [{ openid: [] }],
};
// build operations
for (const funcName of Object.keys(apiHandler.version[version])) {
let operation:OpenAPIOperations = {};
let operation: OpenAPIOperations = {};
if (operations[funcName]) {
operation = {...operations[funcName]};
operation = { ...operations[funcName] };
} else {
// console.warn(`No operation found for function: ${funcName}`);
operation = {
@ -519,15 +546,17 @@ const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT)
// set parameters
operation.parameters = operation.parameters || [];
for (const paramName of apiHandler.version[version][funcName]) {
operation.parameters.push({$ref: `#/components/parameters/${paramName}`});
operation.parameters.push({
$ref: `#/components/parameters/${paramName}`,
});
// @ts-ignore
if (!definition.components.parameters[paramName]) {
// @ts-ignore
definition.components.parameters[paramName] = {
name: paramName,
in: 'query',
in: "query",
schema: {
type: 'string',
type: "string",
},
};
}
@ -557,7 +586,7 @@ const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT)
return definition;
};
exports.expressPreSession = async (hookName:string, {app}:any) => {
exports.expressPreSession = async (hookName: string, { app }: any) => {
// create openapi-backend handlers for each api version under /api/{version}/*
for (const version of Object.keys(apiHandler.version)) {
// we support two different styles of api: flat + rest
@ -570,18 +599,24 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
const definition = generateDefinitionForVersion(version, style);
// serve version specific openapi definition
app.get(`${apiRoot}/openapi.json`, (req:any, res:any) => {
app.get(`${apiRoot}/openapi.json`, (req: any, res: any) => {
// For openapi definitions, wide CORS is probably fine
res.header('Access-Control-Allow-Origin', '*');
res.json({...definition, servers: [generateServerForApiVersion(apiRoot, req)]});
res.header("Access-Control-Allow-Origin", "*");
res.json({
...definition,
servers: [generateServerForApiVersion(apiRoot, req)],
});
});
// serve latest openapi definition file under /api/openapi.json
const isLatestAPIVersion = version === apiHandler.latestApiVersion;
if (isLatestAPIVersion) {
app.get(`/${style}/openapi.json`, (req:any, res:any) => {
res.header('Access-Control-Allow-Origin', '*');
res.json({...definition, servers: [generateServerForApiVersion(apiRoot, req)]});
app.get(`/${style}/openapi.json`, (req: any, res: any) => {
res.header("Access-Control-Allow-Origin", "*");
res.json({
...definition,
servers: [generateServerForApiVersion(apiRoot, req)],
});
});
}
@ -597,22 +632,22 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
// register default handlers
api.register({
notFound: () => {
throw new createHTTPError.NotFound('no such function');
throw new createHTTPError.NotFound("no such function");
},
notImplemented: () => {
throw new createHTTPError.NotImplemented('function not implemented');
throw new createHTTPError.NotImplemented("function not implemented");
},
});
// register operation handlers
for (const funcName of Object.keys(apiHandler.version[version])) {
const handler = async (c: any, req:any, res:any) => {
const handler = async (c: any, req: any, res: any) => {
// parse fields from request
const {header, params, query} = c.request;
const { header, params, query } = c.request;
// read form data if method was POST
let formData:MapArrayType<any> = {};
if (c.request.method === 'post') {
let formData: MapArrayType<any> = {};
if (c.request.method === "post") {
const form = new IncomingForm();
formData = (await form.parse(req))[0];
for (const k of Object.keys(formData)) {
@ -625,7 +660,9 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
const fields = Object.assign({}, header, params, query, formData);
if (logger.isDebugEnabled()) {
logger.debug(`REQUEST, v${version}:${funcName}, ${JSON.stringify(fields)}`);
logger.debug(
`REQUEST, v${version}:${funcName}, ${JSON.stringify(fields)}`,
);
}
// pass to api handler
@ -633,12 +670,12 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
try {
data = await apiHandler.handle(version, funcName, fields, req, res);
} catch (err) {
const errCaused = err as ErrorCaused
const errCaused = err as ErrorCaused;
// convert all errors to http errors
if (createHTTPError.isHttpError(err)) {
// pass http errors thrown by handler forward
throw err;
} else if (errCaused.name === 'apierror') {
} else if (errCaused.name === "apierror") {
// parameters were wrong and the api stopped execution, pass the error
// convert to http error
throw new createHTTPError.BadRequest(errCaused.message);
@ -646,12 +683,12 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
// an unknown error happened
// log it and throw internal error
logger.error(errCaused.stack || errCaused.toString());
throw new createHTTPError.InternalError('internal error');
throw new createHTTPError.InternalError("internal error");
}
}
// return in common format
const response = {code: 0, message: 'ok', data: data || null};
const response = { code: 0, message: "ok", data: data || null };
if (logger.isDebugEnabled()) {
logger.debug(`RESPONSE, ${funcName}, ${JSON.stringify(response)}`);
@ -668,18 +705,18 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
// start and bind to express
await api.init();
app.use(apiRoot, async (req:any, res:any) => {
app.use(apiRoot, async (req: any, res: any) => {
let response = null;
try {
if (style === APIPathStyle.REST) {
// @TODO: Don't allow CORS from everywhere
// This is purely to maintain compatibility with old swagger-node-express
res.header('Access-Control-Allow-Origin', '*');
res.header("Access-Control-Allow-Origin", "*");
}
// pass to openapi-backend handler
response = await api.handleRequest(req, req, res);
} catch (err) {
const errCaused = err as ErrorCaused
const errCaused = err as ErrorCaused;
// handle http errors
// @ts-ignore
res.statusCode = errCaused.statusCode || 500;
@ -688,24 +725,24 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
// https://github.com/ether/etherpad-lite/tree/master/doc/api/http_api.md#response-format
switch (res.statusCode) {
case 403: // forbidden
response = {code: 4, message: errCaused.message, data: null};
response = { code: 4, message: errCaused.message, data: null };
break;
case 401: // unauthorized (no or wrong api key)
response = {code: 4, message: errCaused.message, data: null};
response = { code: 4, message: errCaused.message, data: null };
break;
case 404: // not found (no such function)
response = {code: 3, message: errCaused.message, data: null};
response = { code: 3, message: errCaused.message, data: null };
break;
case 500: // server error (internal error)
response = {code: 2, message: errCaused.message, data: null};
response = { code: 2, message: errCaused.message, data: null };
break;
case 400: // bad request (wrong parameters)
// respond with 200 OK to keep old behavior and pass tests
res.statusCode = 200; // @TODO: this is bad api design
response = {code: 1, message: errCaused.message, data: null};
response = { code: 1, message: errCaused.message, data: null };
break;
default:
response = {code: 1, message: errCaused.message, data: null};
response = { code: 1, message: errCaused.message, data: null };
break;
}
}
@ -723,7 +760,10 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
* @param {APIPathStyle} style The style of the API path
* @return {String} The root path for the API version
*/
const getApiRootForVersion = (version:string, style:any = APIPathStyle.FLAT): string => `/${style}/${version}`;
const getApiRootForVersion = (
version: string,
style: any = APIPathStyle.FLAT,
): string => `/${style}/${version}`;
/**
* Helper to generate an OpenAPI server object when serving definitions
@ -731,8 +771,11 @@ const getApiRootForVersion = (version:string, style:any = APIPathStyle.FLAT): st
* @param {Request} req The express request object
* @return {url: String} The server object for the OpenAPI definition location
*/
const generateServerForApiVersion = (apiRoot:string, req:any): {
url:string
const generateServerForApiVersion = (
apiRoot: string,
req: any,
): {
url: string;
} => ({
url: `${settings.ssl ? 'https' : 'http'}://${req.headers.host}${apiRoot}`,
url: `${settings.ssl ? "https" : "http"}://${req.headers.host}${apiRoot}`,
});

View file

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

View file

@ -1,25 +1,23 @@
'use strict';
import type { ArgsExpressType } from "../../types/ArgsExpressType";
import {ArgsExpressType} from "../../types/ArgsExpressType";
import events from "events";
const express = require("../express");
import log4js from "log4js";
const proxyaddr = require("proxy-addr");
const settings = require("../../utils/Settings");
import { Server, type Socket } from "socket.io";
const socketIORouter = require("../../handler/SocketIORouter");
const hooks = require("../../../static/js/pluginfw/hooks");
const padMessageHandler = require("../../handler/PadMessageHandler");
import events from 'events';
const express = require('../express');
import log4js from 'log4js';
const proxyaddr = require('proxy-addr');
const settings = require('../../utils/Settings');
import {Server, Socket} from 'socket.io'
const socketIORouter = require('../../handler/SocketIORouter');
const hooks = require('../../../static/js/pluginfw/hooks');
const padMessageHandler = require('../../handler/PadMessageHandler');
let io:any;
const logger = log4js.getLogger('socket.io');
let io: any;
const logger = log4js.getLogger("socket.io");
const sockets = new Set();
const socketsEvents = new events.EventEmitter();
export const expressCloseServer = async () => {
if (io == null) return;
logger.info('Closing socket.io engine...');
logger.info("Closing socket.io engine...");
// Close the socket.io engine to disconnect existing clients and reject new clients. Don't call
// io.close() because that closes the underlying HTTP server, which is already done elsewhere.
// (Closing an HTTP server twice throws an exception.) The `engine` property of socket.io Server
@ -39,21 +37,25 @@ export const expressCloseServer = async () => {
// ourselves, so that is what we do.
let lastLogged = 0;
while (sockets.size > 0 && !settings.enableAdminUITests) {
if (Date.now() - lastLogged > 1000) { // Rate limit to avoid filling logs.
logger.info(`Waiting for ${sockets.size} socket.io clients to disconnect...`);
if (Date.now() - lastLogged > 1000) {
// Rate limit to avoid filling logs.
logger.info(
`Waiting for ${sockets.size} socket.io clients to disconnect...`,
);
lastLogged = Date.now();
}
await events.once(socketsEvents, 'updated');
await events.once(socketsEvents, "updated");
}
logger.info('All socket.io clients have disconnected');
logger.info("All socket.io clients have disconnected");
};
const socketSessionMiddleware = (args: any) => (socket: any, next: Function) => {
const socketSessionMiddleware =
(args: any) => (socket: any, next: Function) => {
const req = socket.request;
// Express sets req.ip but socket.io does not. Replicate Express's behavior here.
if (req.ip == null) {
if (settings.trustProxy) {
req.ip = proxyaddr(req, args.app.get('trust proxy fn'));
req.ip = proxyaddr(req, args.app.get("trust proxy fn"));
} else {
req.ip = socket.handshake.address;
}
@ -63,36 +65,39 @@ const socketSessionMiddleware = (args: any) => (socket: any, next: Function) =>
req.headers.cookie = socket.handshake.query.cookie;
}
express.sessionMiddleware(req, {}, next);
};
};
export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {
export const expressCreateServer = (
hookName: string,
args: ArgsExpressType,
cb: Function,
) => {
// init socket.io and redirect all requests to the MessageHandler
// there shouldn't be a browser that isn't compatible to all
// transports in this list at once
// e.g. XHR is disabled in IE by default, so in IE it should use jsonp-polling
io = new Server(args.server,{
io = new Server(args.server, {
transports: settings.socketTransportProtocols,
cookie: false,
maxHttpBufferSize: settings.socketIo.maxHttpBufferSize,
})
});
const handleConnection = (socket:Socket) => {
const handleConnection = (socket: Socket) => {
sockets.add(socket);
socketsEvents.emit('updated');
socketsEvents.emit("updated");
// https://socket.io/docs/v3/faq/index.html
// @ts-ignore
const session = socket.request.session;
session.connections++;
session.save();
socket.on('disconnect', () => {
socket.on("disconnect", () => {
sockets.delete(socket);
socketsEvents.emit('updated');
socketsEvents.emit("updated");
});
}
};
const renewSession = (socket:any, next:Function) => {
socket.conn.on('packet', (packet:string) => {
const renewSession = (socket: any, next: Function) => {
socket.conn.on("packet", (packet: string) => {
// Tell express-session that the session is still active. The session store can use these
// touch events to defer automatic session cleanup, and if express-session is configured with
// rolling=true the cookie's expiration time will be renewed. (Note that WebSockets does not
@ -102,22 +107,21 @@ export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Fu
if (socket.request.session != null) socket.request.session.touch();
});
next();
}
};
io.on('connection', handleConnection);
io.on("connection", handleConnection);
io.use(socketSessionMiddleware(args));
// Temporary workaround so all clients go through middleware and handle connection
io.of('/pluginfw/installer')
.on('connection',handleConnection)
io.of("/pluginfw/installer")
.on("connection", handleConnection)
.use(socketSessionMiddleware(args))
.use(renewSession)
io.of('/settings')
.on('connection',handleConnection)
.use(renewSession);
io.of("/settings")
.on("connection", handleConnection)
.use(socketSessionMiddleware(args))
.use(renewSession)
.use(renewSession);
io.use(renewSession);
@ -134,9 +138,9 @@ export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Fu
// Initialize the Socket.IO Router
socketIORouter.setSocketIO(io);
socketIORouter.addComponent('pad', padMessageHandler);
socketIORouter.addComponent("pad", padMessageHandler);
hooks.callAll('socketio', {app: args.app, io, server: args.server});
hooks.callAll("socketio", { app: args.app, io, server: args.server });
return cb();
};

View file

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

View file

@ -1,38 +1,42 @@
'use strict';
import type { MapArrayType } from "../../types/MapType";
import type { PartType } from "../../types/PartType";
import {MapArrayType} from "../../types/MapType";
import {PartType} from "../../types/PartType";
const fs = require('fs').promises;
const minify = require('../../utils/Minify');
const path = require('path');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const settings = require('../../utils/Settings');
import CachingMiddleware from '../../utils/caching_middleware';
const Yajsml = require('etherpad-yajsml');
const fs = require("fs").promises;
const minify = require("../../utils/Minify");
const path = require("path");
const plugins = require("../../../static/js/pluginfw/plugin_defs");
const settings = require("../../utils/Settings");
import CachingMiddleware from "../../utils/caching_middleware";
const Yajsml = require("etherpad-yajsml");
// Rewrite tar to include modules with no extensions and proper rooted paths.
const getTar = async () => {
const prefixLocalLibraryPath = (path:string) => {
if (path.charAt(0) === '$') {
const prefixLocalLibraryPath = (path: string) => {
if (path.charAt(0) === "$") {
return path.slice(1);
} else {
return `ep_etherpad-lite/static/js/${path}`;
}
};
const tarJson = await fs.readFile(path.join(settings.root, 'src/node/utils/tar.json'), 'utf8');
const tar:MapArrayType<string[]> = {};
for (const [key, relativeFiles] of Object.entries(JSON.parse(tarJson)) as [string, string[]][]) {
const tarJson = await fs.readFile(
path.join(settings.root, "src/node/utils/tar.json"),
"utf8",
);
const tar: MapArrayType<string[]> = {};
for (const [key, relativeFiles] of Object.entries(JSON.parse(tarJson)) as [
string,
string[],
][]) {
const files = relativeFiles.map(prefixLocalLibraryPath);
tar[prefixLocalLibraryPath(key)] = files
.concat(files.map((p) => p.replace(/\.js$/, '')))
.concat(files.map((p) => `${p.replace(/\.js$/, '')}/index.js`));
.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.
const assetCache = new CachingMiddleware();
// Cache static assets
@ -41,21 +45,23 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
// Minify will serve static files compressed (minify enabled). It also has
// file-specific hacks for ace/require-kernel/etc.
app.all('/static/:filename(*)', minify.minify);
app.all("/static/:filename(*)", minify.minify);
// Setup middleware that will package JavaScript files served by minify for
// CommonJS loader on the client-side.
// Hostname "invalid.invalid" is a dummy value to allow parsing as a URI.
const jsServer = new (Yajsml.Server)({
rootPath: 'javascripts/src/',
rootURI: 'http://invalid.invalid/static/js/',
libraryPath: 'javascripts/lib/',
libraryURI: 'http://invalid.invalid/static/plugins/',
const jsServer = new Yajsml.Server({
rootPath: "javascripts/src/",
rootURI: "http://invalid.invalid/static/js/",
libraryPath: "javascripts/lib/",
libraryURI: "http://invalid.invalid/static/plugins/",
requestURIs: minify.requestURIs, // Loop-back is causing problems, this is a workaround.
});
const StaticAssociator = Yajsml.associators.StaticAssociator;
const associations = Yajsml.associators.associationsForSimpleMapping(await getTar());
const associations = Yajsml.associators.associationsForSimpleMapping(
await getTar(),
);
const associator = new StaticAssociator(associations);
jsServer.setAssociator(associator);
@ -64,18 +70,25 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
// serve plugin definitions
// not very static, but served here so that client can do
// require("pluginfw/static/js/plugin-definitions.js");
app.get('/pluginfw/plugin-definitions.json', (req: any, res:any, next:Function) => {
const clientParts = plugins.parts.filter((part: PartType) => part.client_hooks != null);
const clientPlugins:MapArrayType<string> = {};
for (const name of new Set(clientParts.map((part: PartType) => part.plugin))) {
app.get(
"/pluginfw/plugin-definitions.json",
(req: any, res: any, next: Function) => {
const clientParts = plugins.parts.filter(
(part: PartType) => part.client_hooks != null,
);
const clientPlugins: MapArrayType<string> = {};
for (const name of new Set(
clientParts.map((part: PartType) => part.plugin),
)) {
// @ts-ignore
clientPlugins[name] = {...plugins.plugins[name]};
clientPlugins[name] = { ...plugins.plugins[name] };
// @ts-ignore
delete clientPlugins[name].package;
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);
res.write(JSON.stringify({plugins: clientPlugins, parts: clientParts}));
res.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,101 @@
'use strict';
import type { Dirent } from "node:fs";
import type { PluginDef } from "../../types/PartType";
import {Dirent} from "node:fs";
import {PluginDef} from "../../types/PartType";
const path = require('path');
const fsp = require('fs').promises;
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const sanitizePathname = require('../../utils/sanitizePathname');
const settings = require('../../utils/Settings');
const path = require("path");
const fsp = require("fs").promises;
const plugins = require("../../../static/js/pluginfw/plugin_defs");
const sanitizePathname = require("../../utils/sanitizePathname");
const settings = require("../../utils/Settings");
// Returns all *.js files under specDir (recursively) as relative paths to specDir, using '/'
// instead of path.sep to separate pathname components.
const findSpecs = async (specDir: string) => {
let dirents: Dirent[];
try {
dirents = await fsp.readdir(specDir, {withFileTypes: true});
} catch (err:any) {
if (['ENOENT', 'ENOTDIR'].includes(err.code)) return [];
dirents = await fsp.readdir(specDir, { withFileTypes: true });
} catch (err: any) {
if (["ENOENT", "ENOTDIR"].includes(err.code)) return [];
throw err;
}
const specs: string[] = [];
await Promise.all(dirents.map(async (dirent) => {
await Promise.all(
dirents.map(async (dirent) => {
if (dirent.isDirectory()) {
const subdirSpecs = await findSpecs(path.join(specDir, dirent.name));
specs.push(...subdirSpecs.map((spec) => `${dirent.name}/${spec}`));
return;
}
if (!dirent.name.endsWith('.js')) return;
if (!dirent.name.endsWith(".js")) return;
specs.push(dirent.name);
}));
}),
);
return specs;
};
exports.expressPreSession = async (hookName:string, {app}:any) => {
app.get('/tests/frontend/frontendTestSpecs.json', (req:any, res:any, next:Function) => {
exports.expressPreSession = async (hookName: string, { app }: any) => {
app.get(
"/tests/frontend/frontendTestSpecs.json",
(req: any, res: any, next: Function) => {
(async () => {
const modules:string[] = [];
await Promise.all(Object.entries(plugins.plugins).map(async ([plugin, def]) => {
let {package: {path: pluginPath}} = def as PluginDef;
const modules: string[] = [];
await Promise.all(
Object.entries(plugins.plugins).map(async ([plugin, def]) => {
let {
package: { path: pluginPath },
} = def as PluginDef;
if (!pluginPath.endsWith(path.sep)) pluginPath += path.sep;
const specDir = `${plugin === 'ep_etherpad-lite' ? '' : 'static/'}tests/frontend/specs`;
for (const spec of await findSpecs(path.join(pluginPath, specDir))) {
if (plugin === 'ep_etherpad-lite' && !settings.enableAdminUITests &&
spec.startsWith('admin')) continue;
modules.push(`${plugin}/${specDir}/${spec.replace(/\.js$/, '')}`);
const specDir = `${
plugin === "ep_etherpad-lite" ? "" : "static/"
}tests/frontend/specs`;
for (const spec of await findSpecs(
path.join(pluginPath, specDir),
)) {
if (
plugin === "ep_etherpad-lite" &&
!settings.enableAdminUITests &&
spec.startsWith("admin")
)
continue;
modules.push(`${plugin}/${specDir}/${spec.replace(/\.js$/, "")}`);
}
}));
}),
);
// 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/');
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);
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) => {
res.redirect(['./', ...req.url.split('?').slice(1)].join('?'));
app.get("/tests/frontend/index.html", (req: any, res: any) => {
res.redirect(["./", ...req.url.split("?").slice(1)].join("?"));
});
// 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
// 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(
"/tests/frontend/:file([\\d\\D]{0,})",
(req: any, res: any, next: Function) => {
(async () => {
let file = sanitizePathname(req.params.file);
if (['', '.', './'].includes(file)) file = 'index.html';
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) => {
res.redirect(['./frontend/', ...req.url.split('?').slice(1)].join('?'));
app.get("/tests/frontend", (req: any, res: any) => {
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