Added rewrite.

This commit is contained in:
SamTV12345 2024-07-22 14:53:37 +02:00
parent fa2d6d15a9
commit f8175a6433
76 changed files with 3150 additions and 2453 deletions

379
pnpm-lock.yaml generated
View file

@ -143,6 +143,9 @@ importers:
'@etherpad/express-session': '@etherpad/express-session':
specifier: ^1.18.2 specifier: ^1.18.2
version: 1.18.2 version: 1.18.2
'@types/cookie-parser':
specifier: ^1.4.7
version: 1.4.7
async: async:
specifier: ^3.2.5 specifier: ^3.2.5
version: 3.2.5 version: 3.2.5
@ -282,6 +285,12 @@ importers:
'@types/async': '@types/async':
specifier: ^3.2.24 specifier: ^3.2.24
version: 3.2.24 version: 3.2.24
'@types/cross-spawn':
specifier: ^6.0.6
version: 6.0.6
'@types/ejs':
specifier: ^3.1.5
version: 3.1.5
'@types/express': '@types/express':
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.21 version: 4.17.21
@ -300,6 +309,9 @@ importers:
'@types/jsdom': '@types/jsdom':
specifier: ^21.1.7 specifier: ^21.1.7
version: 21.1.7 version: 21.1.7
'@types/jsonminify':
specifier: ^0.4.3
version: 0.4.3
'@types/jsonwebtoken': '@types/jsonwebtoken':
specifier: ^9.0.6 specifier: ^9.0.6
version: 9.0.6 version: 9.0.6
@ -312,6 +324,9 @@ importers:
'@types/oidc-provider': '@types/oidc-provider':
specifier: ^8.5.1 specifier: ^8.5.1
version: 8.5.1 version: 8.5.1
'@types/resolve':
specifier: ^1.20.6
version: 1.20.6
'@types/semver': '@types/semver':
specifier: ^7.5.8 specifier: ^7.5.8
version: 7.5.8 version: 7.5.8
@ -321,6 +336,9 @@ importers:
'@types/supertest': '@types/supertest':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2 version: 6.0.2
'@types/tinycon':
specifier: ^0.6.5
version: 0.6.5
'@types/underscore': '@types/underscore':
specifier: ^1.11.15 specifier: ^1.11.15
version: 1.11.15 version: 1.11.15
@ -366,6 +384,9 @@ importers:
typescript: typescript:
specifier: ^5.5.3 specifier: ^5.5.3
version: 5.5.3 version: 5.5.3
vitest:
specifier: ^2.0.3
version: 2.0.3(@types/node@20.14.11)(jsdom@24.1.0)
ui: ui:
devDependencies: devDependencies:
@ -1459,6 +1480,9 @@ packages:
'@types/content-disposition@0.5.8': '@types/content-disposition@0.5.8':
resolution: {integrity: sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==} resolution: {integrity: sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==}
'@types/cookie-parser@1.4.7':
resolution: {integrity: sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==}
'@types/cookie@0.4.1': '@types/cookie@0.4.1':
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
@ -1471,9 +1495,15 @@ packages:
'@types/cors@2.8.17': '@types/cors@2.8.17':
resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
'@types/cross-spawn@6.0.6':
resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==}
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/ejs@3.1.5':
resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==}
'@types/estree@1.0.5': '@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@ -1516,6 +1546,9 @@ packages:
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/jsonminify@0.4.3':
resolution: {integrity: sha512-+oz7EbPz1Nwmn/sr3UztgXpRhdFpvFrjGi5ictEYxUri5ZvQMTcdTi36MTfD/gCb1A5xhJKdH8Hwz2uz5k6s9A==}
'@types/jsonwebtoken@9.0.6': '@types/jsonwebtoken@9.0.6':
resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==}
@ -1579,6 +1612,9 @@ packages:
'@types/react@18.3.3': '@types/react@18.3.3':
resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==}
'@types/resolve@1.20.6':
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
'@types/semver@7.5.8': '@types/semver@7.5.8':
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
@ -1606,6 +1642,9 @@ packages:
'@types/tar@6.1.13': '@types/tar@6.1.13':
resolution: {integrity: sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==} resolution: {integrity: sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==}
'@types/tinycon@0.6.5':
resolution: {integrity: sha512-RrZzmMXr1P+7NJKQsiTxAxbt87lNMgX6luT0q5Ni96wpvRunOYXUWVStumTnt6ew6oEDkPHQE6o04jUMBTb4Sg==}
'@types/tough-cookie@4.0.5': '@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@ -1697,6 +1736,24 @@ packages:
vite: ^5.0.0 vite: ^5.0.0
vue: ^3.2.25 vue: ^3.2.25
'@vitest/expect@2.0.3':
resolution: {integrity: sha512-X6AepoOYePM0lDNUPsGXTxgXZAl3EXd0GYe/MZyVE4HzkUqyUVC6S3PrY5mClDJ6/7/7vALLMV3+xD/Ko60Hqg==}
'@vitest/pretty-format@2.0.3':
resolution: {integrity: sha512-URM4GLsB2xD37nnTyvf6kfObFafxmycCL8un3OC9gaCs5cti2u+5rJdIflZ2fUJUen4NbvF6jCufwViAFLvz1g==}
'@vitest/runner@2.0.3':
resolution: {integrity: sha512-EmSP4mcjYhAcuBWwqgpjR3FYVeiA4ROzRunqKltWjBfLNs1tnMLtF+qtgd5ClTwkDP6/DGlKJTNa6WxNK0bNYQ==}
'@vitest/snapshot@2.0.3':
resolution: {integrity: sha512-6OyA6v65Oe3tTzoSuRPcU6kh9m+mPL1vQ2jDlPdn9IQoUxl8rXhBnfICNOC+vwxWY684Vt5UPgtcA2aPFBb6wg==}
'@vitest/spy@2.0.3':
resolution: {integrity: sha512-sfqyAw/ypOXlaj4S+w8689qKM1OyPOqnonqOc9T91DsoHbfN5mU7FdifWWv3MtQFf0lEUstEwR9L/q/M390C+A==}
'@vitest/utils@2.0.3':
resolution: {integrity: sha512-c/UdELMuHitQbbc/EVctlBaxoYAwQPQdSNwv7z/vHyBKy2edYZaFgptE27BRueZB7eW8po+cllotMNTDpL3HWg==}
'@vue/compiler-core@3.4.31': '@vue/compiler-core@3.4.31':
resolution: {integrity: sha512-skOiodXWTV3DxfDhB4rOf3OGalpITLlgCeOwb+Y9GJpfQ8ErigdBUHomBzvG78JoVE8MJoQsb+qhZiHfKeNeEg==} resolution: {integrity: sha512-skOiodXWTV3DxfDhB4rOf3OGalpITLlgCeOwb+Y9GJpfQ8ErigdBUHomBzvG78JoVE8MJoQsb+qhZiHfKeNeEg==}
@ -1881,6 +1938,10 @@ packages:
asap@2.0.6: asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-types@0.13.4: ast-types@0.13.4:
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -1961,6 +2022,10 @@ packages:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
cache-content-type@1.0.1: cache-content-type@1.0.1:
resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==} resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==}
engines: {node: '>= 6.0.0'} engines: {node: '>= 6.0.0'}
@ -1991,6 +2056,10 @@ packages:
ccount@2.0.1: ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chai@5.1.1:
resolution: {integrity: sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==}
engines: {node: '>=12'}
chalk@2.4.2: chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -2005,6 +2074,10 @@ packages:
character-entities-legacy@3.0.0: character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'}
chokidar@3.6.0: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
@ -2180,6 +2253,10 @@ packages:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deep-equal@1.0.1: deep-equal@1.0.1:
resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==}
@ -2513,6 +2590,9 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3: esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2530,6 +2610,10 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
execa@8.0.1:
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
engines: {node: '>=16.17'}
express-rate-limit@7.3.1: express-rate-limit@7.3.1:
resolution: {integrity: sha512-BbaryvkY4wEgDqLgD18/NSy2lDO2jTuT9Y8c1Mpx0X63Yz0sYd5zN6KPe7UvpuSVvV33T6RaE1o1IVZQjHMYgw==} resolution: {integrity: sha512-BbaryvkY4wEgDqLgD18/NSy2lDO2jTuT9Y8c1Mpx0X63Yz0sYd5zN6KPe7UvpuSVvV33T6RaE1o1IVZQjHMYgw==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
@ -2691,6 +2775,9 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*} engines: {node: 6.* || 8.* || >= 10.*}
get-func-name@2.0.2:
resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==}
get-intrinsic@1.2.4: get-intrinsic@1.2.4:
resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2703,6 +2790,10 @@ packages:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'} engines: {node: '>=10'}
get-stream@8.0.1:
resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
engines: {node: '>=16'}
get-symbol-description@1.0.2: get-symbol-description@1.0.2:
resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2868,6 +2959,10 @@ packages:
resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==} resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
human-signals@5.0.0:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'}
i18next-browser-languagedetector@8.0.0: i18next-browser-languagedetector@8.0.0:
resolution: {integrity: sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==} resolution: {integrity: sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==}
@ -3011,6 +3106,10 @@ packages:
resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-stream@3.0.0:
resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
is-string@1.0.7: is-string@1.0.7:
resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3220,6 +3319,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
loupe@3.1.1:
resolution: {integrity: sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==}
lower-case@2.0.2: lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
@ -3263,6 +3365,9 @@ packages:
merge-descriptors@1.0.1: merge-descriptors@1.0.1:
resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==}
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
merge2@1.4.1: merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -3308,6 +3413,10 @@ packages:
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
hasBin: true hasBin: true
mimic-fn@4.0.0:
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
engines: {node: '>=12'}
mimic-response@3.1.0: mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -3426,6 +3535,10 @@ packages:
resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==} resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
npm-run-path@5.3.0:
resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
nwsapi@2.2.10: nwsapi@2.2.10:
resolution: {integrity: sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==} resolution: {integrity: sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==}
@ -3481,6 +3594,10 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
onetime@6.0.0:
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
engines: {node: '>=12'}
only@0.0.2: only@0.0.2:
resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==} resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==}
@ -3547,6 +3664,10 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
path-key@4.0.0:
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
engines: {node: '>=12'}
path-parse@1.0.7: path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@ -3560,6 +3681,13 @@ packages:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'} engines: {node: '>=8'}
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
pathval@2.0.0:
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
engines: {node: '>= 14.16'}
perfect-debounce@1.0.0: perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@ -3882,9 +4010,16 @@ packages:
resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7: signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sinon@18.0.0: sinon@18.0.0:
resolution: {integrity: sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==} resolution: {integrity: sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==}
@ -3943,6 +4078,9 @@ packages:
sprintf-js@1.1.3: sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@1.5.0: statuses@1.5.0:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -3951,6 +4089,9 @@ packages:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
std-env@3.7.0:
resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==}
streamroller@3.1.5: streamroller@3.1.5:
resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==} resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@ -3981,6 +4122,10 @@ packages:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'} engines: {node: '>=4'}
strip-final-newline@3.0.0:
resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}
engines: {node: '>=12'}
strip-json-comments@3.1.1: strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -4047,9 +4192,24 @@ packages:
tiny-worker@2.3.0: tiny-worker@2.3.0:
resolution: {integrity: sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==} resolution: {integrity: sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==}
tinybench@2.8.0:
resolution: {integrity: sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==}
tinycon@0.6.8: tinycon@0.6.8:
resolution: {integrity: sha512-bF8Lxm4JUXF6Cw0XlZdugJ44GV575OinZ0Pt8vQPr8ooNqd2yyNkoFdCHzmdpHlgoqfSLfcyk4HDP1EyllT+ug==} resolution: {integrity: sha512-bF8Lxm4JUXF6Cw0XlZdugJ44GV575OinZ0Pt8vQPr8ooNqd2yyNkoFdCHzmdpHlgoqfSLfcyk4HDP1EyllT+ug==}
tinypool@1.0.0:
resolution: {integrity: sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@1.2.0:
resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
engines: {node: '>=14.0.0'}
tinyspy@3.0.0:
resolution: {integrity: sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==}
engines: {node: '>=14.0.0'}
to-fast-properties@2.0.0: to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -4246,6 +4406,11 @@ packages:
vfile@6.0.1: vfile@6.0.1:
resolution: {integrity: sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==} resolution: {integrity: sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==}
vite-node@2.0.3:
resolution: {integrity: sha512-14jzwMx7XTcMB+9BhGQyoEAmSl0eOr3nrnn+Z12WNERtOvLN+d2scbRUvyni05rT3997Bg+rZb47NyP4IQPKXg==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
vite-plugin-static-copy@1.0.6: vite-plugin-static-copy@1.0.6:
resolution: {integrity: sha512-3uSvsMwDVFZRitqoWHj0t4137Kz7UynnJeq1EZlRW7e25h2068fyIZX4ORCCOAkfp1FklGxJNVJBkBOD+PZIew==} resolution: {integrity: sha512-3uSvsMwDVFZRitqoWHj0t4137Kz7UynnJeq1EZlRW7e25h2068fyIZX4ORCCOAkfp1FklGxJNVJBkBOD+PZIew==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@ -4297,6 +4462,31 @@ packages:
postcss: postcss:
optional: true optional: true
vitest@2.0.3:
resolution: {integrity: sha512-o3HRvU93q6qZK4rI2JrhKyZMMuxg/JRt30E6qeQs6ueaiz5hr1cPj+Sk2kATgQzMMqsa2DiNI0TIK++1ULx8Jw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/node': ^18.0.0 || >=20.0.0
'@vitest/browser': 2.0.3
'@vitest/ui': 2.0.3
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
void-elements@3.1.0: void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -4359,6 +4549,11 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -5424,6 +5619,10 @@ snapshots:
'@types/content-disposition@0.5.8': {} '@types/content-disposition@0.5.8': {}
'@types/cookie-parser@1.4.7':
dependencies:
'@types/express': 4.17.21
'@types/cookie@0.4.1': {} '@types/cookie@0.4.1': {}
'@types/cookiejar@2.1.5': {} '@types/cookiejar@2.1.5': {}
@ -5439,10 +5638,16 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.14.11 '@types/node': 20.14.11
'@types/cross-spawn@6.0.6':
dependencies:
'@types/node': 20.14.11
'@types/debug@4.1.12': '@types/debug@4.1.12':
dependencies: dependencies:
'@types/ms': 0.7.34 '@types/ms': 0.7.34
'@types/ejs@3.1.5': {}
'@types/estree@1.0.5': {} '@types/estree@1.0.5': {}
'@types/express-serve-static-core@4.19.3': '@types/express-serve-static-core@4.19.3':
@ -5493,6 +5698,8 @@ snapshots:
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/jsonminify@0.4.3': {}
'@types/jsonwebtoken@9.0.6': '@types/jsonwebtoken@9.0.6':
dependencies: dependencies:
'@types/node': 20.14.11 '@types/node': 20.14.11
@ -5566,6 +5773,8 @@ snapshots:
'@types/prop-types': 15.7.12 '@types/prop-types': 15.7.12
csstype: 3.1.3 csstype: 3.1.3
'@types/resolve@1.20.6': {}
'@types/semver@7.5.8': {} '@types/semver@7.5.8': {}
'@types/send@0.17.4': '@types/send@0.17.4':
@ -5603,6 +5812,8 @@ snapshots:
'@types/node': 20.14.11 '@types/node': 20.14.11
minipass: 4.2.8 minipass: 4.2.8
'@types/tinycon@0.6.5': {}
'@types/tough-cookie@4.0.5': {} '@types/tough-cookie@4.0.5': {}
'@types/underscore@1.11.15': {} '@types/underscore@1.11.15': {}
@ -5710,6 +5921,39 @@ snapshots:
vite: 5.3.4(@types/node@20.14.11) vite: 5.3.4(@types/node@20.14.11)
vue: 3.4.31(typescript@5.5.3) vue: 3.4.31(typescript@5.5.3)
'@vitest/expect@2.0.3':
dependencies:
'@vitest/spy': 2.0.3
'@vitest/utils': 2.0.3
chai: 5.1.1
tinyrainbow: 1.2.0
'@vitest/pretty-format@2.0.3':
dependencies:
tinyrainbow: 1.2.0
'@vitest/runner@2.0.3':
dependencies:
'@vitest/utils': 2.0.3
pathe: 1.1.2
'@vitest/snapshot@2.0.3':
dependencies:
'@vitest/pretty-format': 2.0.3
magic-string: 0.30.10
pathe: 1.1.2
'@vitest/spy@2.0.3':
dependencies:
tinyspy: 3.0.0
'@vitest/utils@2.0.3':
dependencies:
'@vitest/pretty-format': 2.0.3
estree-walker: 3.0.3
loupe: 3.1.1
tinyrainbow: 1.2.0
'@vue/compiler-core@3.4.31': '@vue/compiler-core@3.4.31':
dependencies: dependencies:
'@babel/parser': 7.24.7 '@babel/parser': 7.24.7
@ -5943,6 +6187,8 @@ snapshots:
asap@2.0.6: {} asap@2.0.6: {}
assertion-error@2.0.1: {}
ast-types@0.13.4: ast-types@0.13.4:
dependencies: dependencies:
tslib: 2.6.3 tslib: 2.6.3
@ -6028,6 +6274,8 @@ snapshots:
bytes@3.1.2: {} bytes@3.1.2: {}
cac@6.7.14: {}
cache-content-type@1.0.1: cache-content-type@1.0.1:
dependencies: dependencies:
mime-types: 2.1.35 mime-types: 2.1.35
@ -6061,6 +6309,14 @@ snapshots:
ccount@2.0.1: {} ccount@2.0.1: {}
chai@5.1.1:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.1
deep-eql: 5.0.2
loupe: 3.1.1
pathval: 2.0.0
chalk@2.4.2: chalk@2.4.2:
dependencies: dependencies:
ansi-styles: 3.2.1 ansi-styles: 3.2.1
@ -6076,6 +6332,8 @@ snapshots:
character-entities-legacy@3.0.0: {} character-entities-legacy@3.0.0: {}
check-error@2.1.1: {}
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3
@ -6233,6 +6491,8 @@ snapshots:
dependencies: dependencies:
mimic-response: 3.1.0 mimic-response: 3.1.0
deep-eql@5.0.2: {}
deep-equal@1.0.1: {} deep-equal@1.0.1: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
@ -6726,6 +6986,10 @@ snapshots:
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.5
esutils@2.0.3: {} esutils@2.0.3: {}
eta@3.4.0: {} eta@3.4.0: {}
@ -6742,6 +7006,18 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
execa@8.0.1:
dependencies:
cross-spawn: 7.0.3
get-stream: 8.0.1
human-signals: 5.0.0
is-stream: 3.0.0
merge-stream: 2.0.0
npm-run-path: 5.3.0
onetime: 6.0.0
signal-exit: 4.1.0
strip-final-newline: 3.0.0
express-rate-limit@7.3.1(express@4.19.2): express-rate-limit@7.3.1(express@4.19.2):
dependencies: dependencies:
express: 4.19.2 express: 4.19.2
@ -6937,6 +7213,8 @@ snapshots:
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}
get-func-name@2.0.2: {}
get-intrinsic@1.2.4: get-intrinsic@1.2.4:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@ -6949,6 +7227,8 @@ snapshots:
get-stream@6.0.1: {} get-stream@6.0.1: {}
get-stream@8.0.1: {}
get-symbol-description@1.0.2: get-symbol-description@1.0.2:
dependencies: dependencies:
call-bind: 1.0.7 call-bind: 1.0.7
@ -7194,6 +7474,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
human-signals@5.0.0: {}
i18next-browser-languagedetector@8.0.0: i18next-browser-languagedetector@8.0.0:
dependencies: dependencies:
'@babel/runtime': 7.24.7 '@babel/runtime': 7.24.7
@ -7322,6 +7604,8 @@ snapshots:
dependencies: dependencies:
call-bind: 1.0.7 call-bind: 1.0.7
is-stream@3.0.0: {}
is-string@1.0.7: is-string@1.0.7:
dependencies: dependencies:
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
@ -7580,6 +7864,10 @@ snapshots:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
loupe@3.1.1:
dependencies:
get-func-name: 2.0.2
lower-case@2.0.2: lower-case@2.0.2:
dependencies: dependencies:
tslib: 2.6.3 tslib: 2.6.3
@ -7625,6 +7913,8 @@ snapshots:
merge-descriptors@1.0.1: {} merge-descriptors@1.0.1: {}
merge-stream@2.0.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}
methods@1.1.2: {} methods@1.1.2: {}
@ -7661,6 +7951,8 @@ snapshots:
mime@2.6.0: {} mime@2.6.0: {}
mimic-fn@4.0.0: {}
mimic-response@3.1.0: {} mimic-response@3.1.0: {}
mimic-response@4.0.0: {} mimic-response@4.0.0: {}
@ -7774,6 +8066,10 @@ snapshots:
normalize-url@8.0.1: {} normalize-url@8.0.1: {}
npm-run-path@5.3.0:
dependencies:
path-key: 4.0.0
nwsapi@2.2.10: {} nwsapi@2.2.10: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
@ -7842,6 +8138,10 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
onetime@6.0.0:
dependencies:
mimic-fn: 4.0.0
only@0.0.2: {} only@0.0.2: {}
openapi-backend@5.10.6: openapi-backend@5.10.6:
@ -7932,6 +8232,8 @@ snapshots:
path-key@3.1.1: {} path-key@3.1.1: {}
path-key@4.0.0: {}
path-parse@1.0.7: {} path-parse@1.0.7: {}
path-to-regexp@0.1.7: {} path-to-regexp@0.1.7: {}
@ -7940,6 +8242,10 @@ snapshots:
path-type@4.0.0: {} path-type@4.0.0: {}
pathe@1.1.2: {}
pathval@2.0.0: {}
perfect-debounce@1.0.0: {} perfect-debounce@1.0.0: {}
picocolors@1.0.1: {} picocolors@1.0.1: {}
@ -8292,8 +8598,12 @@ snapshots:
get-intrinsic: 1.2.4 get-intrinsic: 1.2.4
object-inspect: 1.13.1 object-inspect: 1.13.1
siginfo@2.0.0: {}
signal-exit@3.0.7: {} signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
sinon@18.0.0: sinon@18.0.0:
dependencies: dependencies:
'@sinonjs/commons': 3.0.1 '@sinonjs/commons': 3.0.1
@ -8379,10 +8689,14 @@ snapshots:
sprintf-js@1.1.3: {} sprintf-js@1.1.3: {}
stackback@0.0.2: {}
statuses@1.5.0: {} statuses@1.5.0: {}
statuses@2.0.1: {} statuses@2.0.1: {}
std-env@3.7.0: {}
streamroller@3.1.5: streamroller@3.1.5:
dependencies: dependencies:
date-format: 4.0.14 date-format: 4.0.14
@ -8427,6 +8741,8 @@ snapshots:
strip-bom@3.0.0: {} strip-bom@3.0.0: {}
strip-final-newline@3.0.0: {}
strip-json-comments@3.1.1: {} strip-json-comments@3.1.1: {}
superagent@8.1.2: superagent@8.1.2:
@ -8520,8 +8836,16 @@ snapshots:
esm: 3.2.25 esm: 3.2.25
optional: true optional: true
tinybench@2.8.0: {}
tinycon@0.6.8: {} tinycon@0.6.8: {}
tinypool@1.0.0: {}
tinyrainbow@1.2.0: {}
tinyspy@3.0.0: {}
to-fast-properties@2.0.0: {} to-fast-properties@2.0.0: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
@ -8730,6 +9054,23 @@ snapshots:
unist-util-stringify-position: 4.0.0 unist-util-stringify-position: 4.0.0
vfile-message: 4.0.2 vfile-message: 4.0.2
vite-node@2.0.3(@types/node@20.14.11):
dependencies:
cac: 6.7.14
debug: 4.3.5(supports-color@8.1.1)
pathe: 1.1.2
tinyrainbow: 1.2.0
vite: 5.3.4(@types/node@20.14.11)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- stylus
- sugarss
- supports-color
- terser
vite-plugin-static-copy@1.0.6(vite@5.3.4(@types/node@20.14.11)): vite-plugin-static-copy@1.0.6(vite@5.3.4(@types/node@20.14.11)):
dependencies: dependencies:
chokidar: 3.6.0 chokidar: 3.6.0
@ -8805,6 +9146,39 @@ snapshots:
- typescript - typescript
- universal-cookie - universal-cookie
vitest@2.0.3(@types/node@20.14.11)(jsdom@24.1.0):
dependencies:
'@ampproject/remapping': 2.3.0
'@vitest/expect': 2.0.3
'@vitest/pretty-format': 2.0.3
'@vitest/runner': 2.0.3
'@vitest/snapshot': 2.0.3
'@vitest/spy': 2.0.3
'@vitest/utils': 2.0.3
chai: 5.1.1
debug: 4.3.5(supports-color@8.1.1)
execa: 8.0.1
magic-string: 0.30.10
pathe: 1.1.2
std-env: 3.7.0
tinybench: 2.8.0
tinypool: 1.0.0
tinyrainbow: 1.2.0
vite: 5.3.4(@types/node@20.14.11)
vite-node: 2.0.3(@types/node@20.14.11)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.14.11
jsdom: 24.1.0
transitivePeerDependencies:
- less
- lightningcss
- sass
- stylus
- sugarss
- supports-color
- terser
void-elements@3.1.0: {} void-elements@3.1.0: {}
vue-demi@0.14.8(vue@3.4.31(typescript@5.5.3)): vue-demi@0.14.8(vue@3.4.31(typescript@5.5.3)):
@ -8862,6 +9236,11 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
workerpool@6.5.1: {} workerpool@6.5.1: {}

View file

@ -19,59 +19,61 @@
* limitations under the License. * limitations under the License.
*/ */
const Changeset = require('../../static/js/Changeset'); import {deserializeOps} from '../../static/js/Changeset';
const ChatMessage = require('../../static/js/ChatMessage'); import ChatMessage from '../../static/js/ChatMessage';
const CustomError = require('../utils/customError'); import CustomError from '../utils/customError';
const padManager = require('./PadManager'); import * as padManager from './PadManager';
const padMessageHandler = require('../handler/PadMessageHandler'); import * as padMessageHandler from '../handler/PadMessageHandler';
const readOnlyManager = require('./ReadOnlyManager'); import {getPadId, getReadOnlyId} from './ReadOnlyManager';
const groupManager = require('./GroupManager'); import * as groupManager from './GroupManager';
const authorManager = require('./AuthorManager'); import * as authorManager from './AuthorManager';
const sessionManager = require('./SessionManager'); import * as sessionManager from './SessionManager';
const exportHtml = require('../utils/ExportHtml'); import {getPadHTML} from '../utils/ExportHtml';
const exportTxt = require('../utils/ExportTxt'); import {getTXTFromAtext} from '../utils/ExportTxt';
const importHtml = require('../utils/ImportHtml'); import {setPadHTML} from '../utils/ImportHtml';
const cleanText = require('./Pad').cleanText; import {cleanText} from './Pad'
const PadDiff = require('../utils/padDiff'); import PadDiff from '../utils/padDiff';
const {checkValidRev, isInt} = require('../utils/checkValidRev'); import {checkValidRev, isInt} from '../utils/checkValidRev';
import {Builder} from "../../static/js/Builder";
import {Attribute} from "../../static/js/types/Attribute";
/* ******************** /* ********************
* GROUP FUNCTIONS **** * GROUP FUNCTIONS ****
******************** */ ******************** */
exports.listAllGroups = groupManager.listAllGroups; export const listAllGroups = groupManager.listAllGroups;
exports.createGroup = groupManager.createGroup; export const createGroup = groupManager.createGroup;
exports.createGroupIfNotExistsFor = groupManager.createGroupIfNotExistsFor; export const createGroupIfNotExistsFor = groupManager.createGroupIfNotExistsFor;
exports.deleteGroup = groupManager.deleteGroup; export const deleteGroup = groupManager.deleteGroup;
exports.listPads = groupManager.listPads; export const listPads = groupManager.listPads;
exports.createGroupPad = groupManager.createGroupPad; export const createGroupPad = groupManager.createGroupPad;
/* ******************** /* ********************
* PADLIST FUNCTION *** * PADLIST FUNCTION ***
******************** */ ******************** */
exports.listAllPads = padManager.listAllPads; export const listAllPads = padManager.listAllPads;
/* ******************** /* ********************
* AUTHOR FUNCTIONS *** * AUTHOR FUNCTIONS ***
******************** */ ******************** */
exports.createAuthor = authorManager.createAuthor; export const createAuthor = authorManager.createAuthor;
exports.createAuthorIfNotExistsFor = authorManager.createAuthorIfNotExistsFor; export const createAuthorIfNotExistsFor = authorManager.createAuthorIfNotExistsFor;
exports.getAuthorName = authorManager.getAuthorName; export const getAuthorName = authorManager.getAuthorName;
exports.listPadsOfAuthor = authorManager.listPadsOfAuthor; export const listPadsOfAuthor = authorManager.listPadsOfAuthor;
exports.padUsers = padMessageHandler.padUsers; export const padUsers = padMessageHandler.padUsers;
exports.padUsersCount = padMessageHandler.padUsersCount; export const padUsersCount = padMessageHandler.padUsersCount;
/* ******************** /* ********************
* SESSION FUNCTIONS ** * SESSION FUNCTIONS **
******************** */ ******************** */
exports.createSession = sessionManager.createSession; export const createSession = sessionManager.createSession;
exports.deleteSession = sessionManager.deleteSession; export const deleteSession = sessionManager.deleteSession;
exports.getSessionInfo = sessionManager.getSessionInfo; export const getSessionInfo = sessionManager.getSessionInfo;
exports.listSessionsOfGroup = sessionManager.listSessionsOfGroup; export const listSessionsOfGroup = sessionManager.listSessionsOfGroup;
exports.listSessionsOfAuthor = sessionManager.listSessionsOfAuthor; export const listSessionsOfAuthor = sessionManager.listSessionsOfAuthor;
/* *********************** /* ***********************
* PAD CONTENT FUNCTIONS * * PAD CONTENT FUNCTIONS *
@ -104,7 +106,7 @@ Example returns:
} }
*/ */
exports.getAttributePool = async (padID: string) => { export const getAttributePool = async (padID: string) => {
const pad = await getPadSafe(padID, true); const pad = await getPadSafe(padID, true);
return {pool: pad.pool}; return {pool: pad.pool};
}; };
@ -122,7 +124,7 @@ Example returns:
} }
*/ */
exports.getRevisionChangeset = async (padID: string, rev: string) => { export const getRevisionChangeset = async (padID: string, rev: number) => {
// try to parse the revision number // try to parse the revision number
if (rev !== undefined) { if (rev !== undefined) {
rev = checkValidRev(rev); rev = checkValidRev(rev);
@ -155,7 +157,7 @@ Example returns:
{code: 0, message:"ok", data: {text:"Welcome Text"}} {code: 0, message:"ok", data: {text:"Welcome Text"}}
{code: 1, message:"padID does not exist", data: null} {code: 1, message:"padID does not exist", data: null}
*/ */
exports.getText = async (padID: string, rev: string) => { export const getText = async (padID: string, rev: number) => {
// try to parse the revision number // try to parse the revision number
if (rev !== undefined) { if (rev !== undefined) {
rev = checkValidRev(rev); rev = checkValidRev(rev);
@ -180,7 +182,7 @@ exports.getText = async (padID: string, rev: string) => {
} }
// the client wants the latest text, lets return it to him // the client wants the latest text, lets return it to him
const text = exportTxt.getTXTFromAtext(pad, pad.atext); const text = getTXTFromAtext(pad, pad.atext);
return {text}; return {text};
}; };
@ -200,7 +202,7 @@ Example returns:
* @param {String} authorId the id of the author, defaulting to empty string * @param {String} authorId the id of the author, defaulting to empty string
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
exports.setText = async (padID: string, text?: string, authorId: string = ''): Promise<void> => { export const setText = async (padID: string, text?: string, authorId: string = ''): Promise<void> => {
// text is required // text is required
if (typeof text !== 'string') { if (typeof text !== 'string') {
throw new CustomError('text is not a string', 'apierror'); throw new CustomError('text is not a string', 'apierror');
@ -225,7 +227,7 @@ Example returns:
@param {String} text the text of the pad @param {String} text the text of the pad
@param {String} authorId the id of the author, defaulting to empty string @param {String} authorId the id of the author, defaulting to empty string
*/ */
exports.appendText = async (padID:string, text?: string, authorId:string = '') => { export const appendText = async (padID:string, text?: string, authorId:string = '') => {
// text is required // text is required
if (typeof text !== 'string') { if (typeof text !== 'string') {
throw new CustomError('text is not a string', 'apierror'); throw new CustomError('text is not a string', 'apierror');
@ -247,7 +249,7 @@ Example returns:
@param {String} rev the revision number, defaulting to the latest revision @param {String} rev the revision number, defaulting to the latest revision
@return {Promise<{html: string}>} the html of the pad @return {Promise<{html: string}>} the html of the pad
*/ */
exports.getHTML = async (padID: string, rev: string): Promise<{ html: string; }> => { export const getHTML = async (padID: string, rev: number): Promise<{ html: string; }> => {
if (rev !== undefined) { if (rev !== undefined) {
rev = checkValidRev(rev); rev = checkValidRev(rev);
} }
@ -264,7 +266,7 @@ exports.getHTML = async (padID: string, rev: string): Promise<{ html: string; }>
} }
// get the html of this revision // get the html of this revision
let html = await exportHtml.getPadHTML(pad, rev); let html = await getPadHTML(pad, rev);
// wrap the HTML // wrap the HTML
html = `<!DOCTYPE HTML><html><body>${html}</body></html>`; html = `<!DOCTYPE HTML><html><body>${html}</body></html>`;
@ -283,7 +285,7 @@ Example returns:
@param {String} html the html of the pad @param {String} html the html of the pad
@param {String} authorId the id of the author, defaulting to empty string @param {String} authorId the id of the author, defaulting to empty string
*/ */
exports.setHTML = async (padID: string, html:string|object, authorId = '') => { export const setHTML = async (padID: string, html:string|object, authorId = '') => {
// html string is required // html string is required
if (typeof html !== 'string') { if (typeof html !== 'string') {
throw new CustomError('html is not a string', 'apierror'); throw new CustomError('html is not a string', 'apierror');
@ -294,13 +296,13 @@ exports.setHTML = async (padID: string, html:string|object, authorId = '') => {
// add a new changeset with the new html to the pad // add a new changeset with the new html to the pad
try { try {
await importHtml.setPadHTML(pad, cleanText(html), authorId); await setPadHTML(pad, cleanText(html), authorId);
} catch (e) { } catch (e) {
throw new CustomError('HTML is malformed', 'apierror'); throw new CustomError('HTML is malformed', 'apierror');
} }
// update the clients on the pad // update the clients on the pad
padMessageHandler.updatePadClients(pad); await padMessageHandler.updatePadClients(pad);
}; };
/* **************** /* ****************
@ -324,7 +326,7 @@ Example returns:
@param {Number} start the start point of the chat-history @param {Number} start the start point of the chat-history
@param {Number} end the end point of the chat-history @param {Number} end the end point of the chat-history
*/ */
exports.getChatHistory = async (padID: string, start:number, end:number) => { export const getChatHistory = async (padID: string, start:number, end:number) => {
if (start && end) { if (start && end) {
if (start < 0) { if (start < 0) {
throw new CustomError('start is below zero', 'apierror'); throw new CustomError('start is below zero', 'apierror');
@ -374,7 +376,7 @@ Example returns:
@param {String} authorID the id of the author @param {String} authorID the id of the author
@param {Number} time the timestamp of the chat-message @param {Number} time the timestamp of the chat-message
*/ */
exports.appendChatMessage = async (padID: string, text: string|object, authorID: string, time: number) => { export const appendChatMessage = async (padID: string, text: string|object, authorID: string, time: number) => {
// text is required // text is required
if (typeof text !== 'string') { if (typeof text !== 'string') {
throw new CustomError('text is not a string', 'apierror'); throw new CustomError('text is not a string', 'apierror');
@ -404,7 +406,7 @@ Example returns:
{code: 1, message:"padID does not exist", data: null} {code: 1, message:"padID does not exist", data: null}
@param {String} padID the id of the pad @param {String} padID the id of the pad
*/ */
exports.getRevisionsCount = async (padID: string) => { export const getRevisionsCount = async (padID: string) => {
// get the pad // get the pad
const pad = await getPadSafe(padID, true); const pad = await getPadSafe(padID, true);
return {revisions: pad.getHeadRevisionNumber()}; return {revisions: pad.getHeadRevisionNumber()};
@ -419,7 +421,7 @@ Example returns:
{code: 1, message:"padID does not exist", data: null} {code: 1, message:"padID does not exist", data: null}
@param {String} padID the id of the pad @param {String} padID the id of the pad
*/ */
exports.getSavedRevisionsCount = async (padID: string) => { export const getSavedRevisionsCount = async (padID: string) => {
// get the pad // get the pad
const pad = await getPadSafe(padID, true); const pad = await getPadSafe(padID, true);
return {savedRevisions: pad.getSavedRevisionsNumber()}; return {savedRevisions: pad.getSavedRevisionsNumber()};
@ -434,7 +436,7 @@ Example returns:
{code: 1, message:"padID does not exist", data: null} {code: 1, message:"padID does not exist", data: null}
@param {String} padID the id of the pad @param {String} padID the id of the pad
*/ */
exports.listSavedRevisions = async (padID: string) => { export const listSavedRevisions = async (padID: string) => {
// get the pad // get the pad
const pad = await getPadSafe(padID, true); const pad = await getPadSafe(padID, true);
return {savedRevisions: pad.getSavedRevisionsList()}; return {savedRevisions: pad.getSavedRevisionsList()};
@ -450,7 +452,7 @@ Example returns:
@param {String} padID the id of the pad @param {String} padID the id of the pad
@param {Number} rev the revision number, defaulting to the latest revision @param {Number} rev the revision number, defaulting to the latest revision
*/ */
exports.saveRevision = async (padID: string, rev: number) => { export const saveRevision = async (padID: string, rev: number) => {
// check if rev is a number // check if rev is a number
if (rev !== undefined) { if (rev !== undefined) {
rev = checkValidRev(rev); rev = checkValidRev(rev);
@ -483,7 +485,7 @@ Example returns:
@param {String} padID the id of the pad @param {String} padID the id of the pad
@return {Promise<{lastEdited: number}>} the timestamp of the last revision of the pad @return {Promise<{lastEdited: number}>} the timestamp of the last revision of the pad
*/ */
exports.getLastEdited = async (padID: string): Promise<{ lastEdited: number; }> => { export const getLastEdited = async (padID: string): Promise<{ lastEdited: number; }> => {
// get the pad // get the pad
const pad = await getPadSafe(padID, true); const pad = await getPadSafe(padID, true);
const lastEdited = await pad.getLastEdit(); const lastEdited = await pad.getLastEdit();
@ -501,7 +503,7 @@ Example returns:
@param {String} text the initial text of the pad @param {String} text the initial text of the pad
@param {String} authorId the id of the author, defaulting to empty string @param {String} authorId the id of the author, defaulting to empty string
*/ */
exports.createPad = async (padID: string, text: string, authorId = '') => { export const createPad = async (padID: string, text: string, authorId = '') => {
if (padID) { if (padID) {
// ensure there is no $ in the padID // ensure there is no $ in the padID
if (padID.indexOf('$') !== -1) { if (padID.indexOf('$') !== -1) {
@ -527,7 +529,7 @@ Example returns:
{code: 1, message:"padID does not exist", data: null} {code: 1, message:"padID does not exist", data: null}
@param {String} padID the id of the pad @param {String} padID the id of the pad
*/ */
exports.deletePad = async (padID: string) => { export const deletePad = async (padID: string) => {
const pad = await getPadSafe(padID, true); const pad = await getPadSafe(padID, true);
await pad.remove(); await pad.remove();
}; };
@ -543,7 +545,7 @@ exports.deletePad = async (padID: string) => {
@param {Number} rev the revision number, defaulting to the latest revision @param {Number} rev the revision number, defaulting to the latest revision
@param {String} authorId the id of the author, defaulting to empty string @param {String} authorId the id of the author, defaulting to empty string
*/ */
exports.restoreRevision = async (padID: string, rev: number, authorId = '') => { export const restoreRevision = async (padID: string, rev: number, authorId = '') => {
// check if rev is a number // check if rev is a number
if (rev === undefined) { if (rev === undefined) {
throw new CustomError('rev is not defined', 'apierror'); throw new CustomError('rev is not defined', 'apierror');
@ -563,11 +565,11 @@ exports.restoreRevision = async (padID: string, rev: number, authorId = '') => {
const oldText = pad.text(); 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; let textIndex = 0;
const newTextStart = 0; const newTextStart = 0;
const newTextEnd = atext.text.length; const newTextEnd = atext.text.length;
for (const op of Changeset.deserializeOps(attribs)) { for (const op of deserializeOps(attribs)) {
const nextIndex = textIndex + op.chars; const nextIndex = textIndex + op.chars;
if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { 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);
@ -577,10 +579,10 @@ exports.restoreRevision = async (padID: string, rev: number, authorId = '') => {
}; };
// create a new changeset with a helper builder object // create a new changeset with a helper builder object
const builder = Changeset.builder(oldText.length); const builder = new Builder(oldText.length);
// assemble each line into the builder // assemble each line into the builder
eachAttribRun(atext.attribs, (start: number, end: number, attribs:string[]) => { eachAttribRun(atext.attribs, (start: number, end: number, attribs: Attribute[]) => {
builder.insert(atext.text.substring(start, end), attribs); builder.insert(atext.text.substring(start, end), attribs);
}); });
@ -588,7 +590,7 @@ exports.restoreRevision = async (padID: string, rev: number, authorId = '') => {
if (lastNewlinePos < 0) { if (lastNewlinePos < 0) {
builder.remove(oldText.length - 1, 0); builder.remove(oldText.length - 1, 0);
} else { } else {
builder.remove(lastNewlinePos, oldText.match(/\n/g).length - 1); builder.remove(lastNewlinePos, oldText.match(/\n/g)!.length - 1);
builder.remove(oldText.length - lastNewlinePos - 1, 0); builder.remove(oldText.length - lastNewlinePos - 1, 0);
} }
@ -610,7 +612,7 @@ Example returns:
@param {String} destinationID the id of the destination pad @param {String} destinationID the id of the destination pad
@param {Boolean} force whether to overwrite the destination pad if it exists @param {Boolean} force whether to overwrite the destination pad if it exists
*/ */
exports.copyPad = async (sourceID: string, destinationID: string, force: boolean) => { export const copyPad = async (sourceID: string, destinationID: string, force: boolean) => {
const pad = await getPadSafe(sourceID, true); const pad = await getPadSafe(sourceID, true);
await pad.copy(destinationID, force); await pad.copy(destinationID, force);
}; };
@ -628,7 +630,7 @@ Example returns:
@param {Boolean} force whether to overwrite the destination pad if it exists @param {Boolean} force whether to overwrite the destination pad if it exists
@param {String} authorId the id of the author, defaulting to empty string @param {String} authorId the id of the author, defaulting to empty string
*/ */
exports.copyPadWithoutHistory = async (sourceID: string, destinationID: string, force:boolean, authorId = '') => { export const copyPadWithoutHistory = async (sourceID: string, destinationID: string, force:boolean, authorId: string = '') => {
const pad = await getPadSafe(sourceID, true); const pad = await getPadSafe(sourceID, true);
await pad.copyPadWithoutHistory(destinationID, force, authorId); await pad.copyPadWithoutHistory(destinationID, force, authorId);
}; };
@ -645,7 +647,7 @@ Example returns:
@param {String} destinationID the id of the destination pad @param {String} destinationID the id of the destination pad
@param {Boolean} force whether to overwrite the destination pad if it exists @param {Boolean} force whether to overwrite the destination pad if it exists
*/ */
exports.movePad = async (sourceID: string, destinationID: string, force:boolean) => { export const movePad = async (sourceID: string, destinationID: string, force:boolean) => {
const pad = await getPadSafe(sourceID, true); const pad = await getPadSafe(sourceID, true);
await pad.copy(destinationID, force); await pad.copy(destinationID, force);
await pad.remove(); await pad.remove();
@ -660,12 +662,12 @@ Example returns:
{code: 1, message:"padID does not exist", data: null} {code: 1, message:"padID does not exist", data: null}
@param {String} padID the id of the pad @param {String} padID the id of the pad
*/ */
exports.getReadOnlyID = async (padID: string) => { export const getReadOnlyID = async (padID: string) => {
// we don't need the pad object, but this function does all the security stuff for us // we don't need the pad object, but this function does all the security stuff for us
await getPadSafe(padID, true); await getPadSafe(padID, true);
// get the readonlyId // get the readonlyId
const readOnlyID = await readOnlyManager.getReadOnlyId(padID); const readOnlyID = await getReadOnlyId(padID);
return {readOnlyID}; return {readOnlyID};
}; };
@ -679,9 +681,9 @@ Example returns:
{code: 1, message:"padID does not exist", data: null} {code: 1, message:"padID does not exist", data: null}
@param {String} roID the readonly id of the pad @param {String} roID the readonly id of the pad
*/ */
exports.getPadID = async (roID: string) => { export const getPadID = async (roID: string) => {
// get the PadId // get the PadId
const padID = await readOnlyManager.getPadId(roID); const padID = await getPadId(roID);
if (padID == null) { if (padID == null) {
throw new CustomError('padID does not exist', 'apierror'); throw new CustomError('padID does not exist', 'apierror');
} }
@ -699,7 +701,7 @@ Example returns:
@param {String} padID the id of the pad @param {String} padID the id of the pad
@param {Boolean} publicStatus the public status of the pad @param {Boolean} publicStatus the public status of the pad
*/ */
exports.setPublicStatus = async (padID: string, publicStatus: boolean|string) => { export const setPublicStatus = async (padID: string, publicStatus: boolean|string) => {
// ensure this is a group pad // ensure this is a group pad
checkGroupPad(padID, 'publicStatus'); checkGroupPad(padID, 'publicStatus');
@ -723,7 +725,7 @@ Example returns:
{code: 1, message:"padID does not exist", data: null} {code: 1, message:"padID does not exist", data: null}
@param {String} padID the id of the pad @param {String} padID the id of the pad
*/ */
exports.getPublicStatus = async (padID: string) => { export const getPublicStatus = async (padID: string) => {
// ensure this is a group pad // ensure this is a group pad
checkGroupPad(padID, 'publicStatus'); checkGroupPad(padID, 'publicStatus');
@ -741,7 +743,7 @@ Example returns:
{code: 1, message:"padID does not exist", data: null} {code: 1, message:"padID does not exist", data: null}
@param {String} padID the id of the pad @param {String} padID the id of the pad
*/ */
exports.listAuthorsOfPad = async (padID: string) => { export const listAuthorsOfPad = async (padID: string) => {
// get the pad // get the pad
const pad = await getPadSafe(padID, true); const pad = await getPadSafe(padID, true);
const authorIDs = pad.getAllAuthors(); const authorIDs = pad.getAllAuthors();
@ -773,7 +775,7 @@ Example returns:
@param {String} msg the message to send @param {String} msg the message to send
*/ */
exports.sendClientsMessage = async (padID: string, msg: string) => { export const sendClientsMessage = async (padID: string, msg: string) => {
await getPadSafe(padID, true); // Throw if the padID is invalid or if the pad does not exist. await getPadSafe(padID, true); // Throw if the padID is invalid or if the pad does not exist.
padMessageHandler.handleCustomMessage(padID, msg); padMessageHandler.handleCustomMessage(padID, msg);
}; };
@ -786,7 +788,7 @@ Example returns:
{"code":0,"message":"ok","data":null} {"code":0,"message":"ok","data":null}
{"code":4,"message":"no or wrong API Key","data":null} {"code":4,"message":"no or wrong API Key","data":null}
*/ */
exports.checkToken = async () => { export const checkToken = async () => {
}; };
/** /**
@ -799,7 +801,7 @@ Example returns:
@param {String} padID the id of the pad @param {String} padID the id of the pad
@return {Promise<{chatHead: number}>} the chatHead of the pad @return {Promise<{chatHead: number}>} the chatHead of the pad
*/ */
exports.getChatHead = async (padID:string): Promise<{ chatHead: number; }> => { export const getChatHead = async (padID:string): Promise<{ chatHead: number; }> => {
// get the pad // get the pad
const pad = await getPadSafe(padID, true); const pad = await getPadSafe(padID, true);
return {chatHead: pad.chatHead}; return {chatHead: pad.chatHead};
@ -825,7 +827,7 @@ Example returns:
@param {Number} startRev the start revision number @param {Number} startRev the start revision number
@param {Number} endRev the end revision number @param {Number} endRev the end revision number
*/ */
exports.createDiffHTML = async (padID: string, startRev: number, endRev: number) => { export const createDiffHTML = async (padID: string, startRev: number, endRev: number) => {
// check if startRev is a number // check if startRev is a number
if (startRev !== undefined) { if (startRev !== undefined) {
startRev = checkValidRev(startRev); startRev = checkValidRev(startRev);
@ -901,7 +903,7 @@ const getPadSafe = async (padID: string|object, shouldExist: boolean, text?:stri
} }
// check if the pad exists // check if the pad exists
const exists = await padManager.doesPadExists(padID); const exists = await padManager.doesPadExist(padID);
if (!exists && shouldExist) { if (!exists && shouldExist) {
// does not exist, but should // does not exist, but should

View file

@ -19,12 +19,12 @@
* limitations under the License. * limitations under the License.
*/ */
const db = require('./DB'); import {get, getSub, set, setSub} from './DB';
const CustomError = require('../utils/customError'); import CustomError from '../utils/customError';
const hooks = require('../../static/js/pluginfw/hooks.js'); import {aCallFirst} from '../../static/js/pluginfw/hooks';
import {padUtils, randomString} from '../../static/js/pad_utils' import {padUtils, randomString} from '../../static/js/pad_utils'
exports.getColorPalette = () => [ export const getColorPalette = () => [
'#ffc7c7', '#ffc7c7',
'#fff1c7', '#fff1c7',
'#e3ffc7', '#e3ffc7',
@ -95,8 +95,8 @@ exports.getColorPalette = () => [
* Checks if the author exists * Checks if the author exists
* @param {String} authorID The id of the author * @param {String} authorID The id of the author
*/ */
exports.doesAuthorExist = async (authorID: string) => { export const doesAuthorExist = async (authorID: string) => {
const author = await db.get(`globalAuthor:${authorID}`); const author = await get(`globalAuthor:${authorID}`);
return author != null; return author != null;
}; };
@ -105,7 +105,6 @@ exports.doesAuthorExist = async (authorID: string) => {
exported for backwards compatibility exported for backwards compatibility
@param {String} authorID The id of the author @param {String} authorID The id of the author
*/ */
exports.doesAuthorExists = exports.doesAuthorExist;
/** /**
@ -116,14 +115,14 @@ exports.doesAuthorExists = exports.doesAuthorExist;
*/ */
const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => { const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => {
// try to map to an author // try to map to an author
const author = await db.get(`${mapperkey}:${mapper}`); const author = await get(`${mapperkey}:${mapper}`);
if (author == null) { if (author == null) {
// there is no author with this mapper, so create one // there is no author with this mapper, so create one
const author = await exports.createAuthor(null); const author = await exports.createAuthor(null);
// create the token2author relation // create the token2author relation
await db.set(`${mapperkey}:${mapper}`, author.authorID); await set(`${mapperkey}:${mapper}`, author.authorID);
// return the author // return the author
return author; return author;
@ -131,7 +130,8 @@ const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => {
// there is an author with this mapper // there is an author with this mapper
// update the timestamp of this author // update the timestamp of this author
await db.setSub(`globalAuthor:${author}`, ['timestamp'], Date.now()); // @ts-ignore
await db!.setSub(`globalAuthor:${author}`, ['timestamp'], Date.now());
// return the author // return the author
return {authorID: author}; return {authorID: author};
@ -142,7 +142,7 @@ const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => {
* @param {String} token The token of the author * @param {String} token The token of the author
* @return {Promise<string|*|{authorID: string}|{authorID: *}>} * @return {Promise<string|*|{authorID: string}|{authorID: *}>}
*/ */
const getAuthor4Token = async (token: string) => { const _getAuthor4Token = async (token: string) => {
const author = await mapAuthorWithDBKey('token2author', token); const author = await mapAuthorWithDBKey('token2author', token);
// return only the sub value authorID // return only the sub value authorID
@ -155,9 +155,9 @@ const getAuthor4Token = async (token: string) => {
* @param {Object} user * @param {Object} user
* @return {Promise<*>} * @return {Promise<*>}
*/ */
exports.getAuthorId = async (token: string, user: object) => { export const getAuthorId = async (token: string, user: object) => {
const context = {dbKey: token, token, user}; const context = {dbKey: token, token, user};
let [authorId] = await hooks.aCallFirst('getAuthorId', context); let [authorId] = await aCallFirst('getAuthorId', context);
if (!authorId) authorId = await getAuthor4Token(context.dbKey); if (!authorId) authorId = await getAuthor4Token(context.dbKey);
return authorId; return authorId;
}; };
@ -168,10 +168,10 @@ exports.getAuthorId = async (token: string, user: object) => {
* @deprecated Use `getAuthorId` instead. * @deprecated Use `getAuthorId` instead.
* @param {String} token The token * @param {String} token The token
*/ */
exports.getAuthor4Token = async (token: string) => { export const getAuthor4Token = async (token: string) => {
padUtils.warnDeprecated( padUtils.warnDeprecated(
'AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead'); 'AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead');
return await getAuthor4Token(token); return await _getAuthor4Token(token);
}; };
/** /**
@ -179,12 +179,12 @@ exports.getAuthor4Token = async (token: string) => {
* @param {String} authorMapper The mapper * @param {String} authorMapper The mapper
* @param {String} name The name of the author (optional) * @param {String} name The name of the author (optional)
*/ */
exports.createAuthorIfNotExistsFor = async (authorMapper: string, name: string) => { export const createAuthorIfNotExistsFor = async (authorMapper: string, name: string) => {
const author = await mapAuthorWithDBKey('mapper2author', authorMapper); const author = await mapAuthorWithDBKey('mapper2author', authorMapper);
if (name) { if (name) {
// set the name of this author // set the name of this author
await exports.setAuthorName(author.authorID, name); await setAuthorName(author.authorID, name);
} }
return author; return author;
@ -195,19 +195,20 @@ exports.createAuthorIfNotExistsFor = async (authorMapper: string, name: string)
* Internal function that creates the database entry for an author * Internal function that creates the database entry for an author
* @param {String} name The name of the author * @param {String} name The name of the author
*/ */
exports.createAuthor = async (name: string) => { export const createAuthor = async (name: string) => {
// create the new author name // create the new author name
const author = `a.${randomString(16)}`; const author = `a.${randomString(16)}`;
// create the globalAuthors db entry // create the globalAuthors db entry
const authorObj = { const authorObj = {
colorId: Math.floor(Math.random() * (exports.getColorPalette().length)), colorId: Math.floor(Math.random() * (getColorPalette().length)),
name, name,
timestamp: Date.now(), timestamp: Date.now(),
}; };
// set the global author db entry // set the global author db entry
await db.set(`globalAuthor:${author}`, authorObj); // @ts-ignore
await db!.set(`globalAuthor:${author}`, authorObj);
return {authorID: author}; return {authorID: author};
}; };
@ -216,48 +217,49 @@ exports.createAuthor = async (name: string) => {
* Returns the Author Obj of the author * Returns the Author Obj of the author
* @param {String} author The id of the author * @param {String} author The id of the author
*/ */
exports.getAuthor = async (author: string) => await db.get(`globalAuthor:${author}`); export const getAuthor = async (author: string) => await get(`globalAuthor:${author}`);
/** /**
* Returns the color Id of the author * Returns the color Id of the author
* @param {String} author The id of the author * @param {String} author The id of the author
*/ */
exports.getAuthorColorId = async (author: string) => await db.getSub(`globalAuthor:${author}`, ['colorId']); export const getAuthorColorId = async (author: string) => await getSub(`globalAuthor:${author}`, ['colorId']) as number;
/** /**
* Sets the color Id of the author * Sets the color Id of the author
* @param {String} author The id of the author * @param {String} author The id of the author
* @param {String} colorId The color id of the author * @param {String} colorId The color id of the author
*/ */
exports.setAuthorColorId = async (author: string, colorId: string) => await db.setSub( export const setAuthorColorId = async (author: string, colorId: string) => await setSub(
// @ts-ignore
`globalAuthor:${author}`, ['colorId'], colorId); `globalAuthor:${author}`, ['colorId'], colorId);
/** /**
* Returns the name of the author * Returns the name of the author
* @param {String} author The id of the author * @param {String} author The id of the author
*/ */
exports.getAuthorName = async (author: string) => await db.getSub(`globalAuthor:${author}`, ['name']); export const getAuthorName = async (author: string) => await getSub(`globalAuthor:${author}`, ['name']);
/** /**
* Sets the name of the author * Sets the name of the author
* @param {String} author The id of the author * @param {String} author The id of the author
* @param {String} name The name of the author * @param {String} name The name of the author
*/ */
exports.setAuthorName = async (author: string, name: string) => await db.setSub( export const setAuthorName = async (author: string, name: string) => await setSub(
`globalAuthor:${author}`, ['name'], name); `globalAuthor:${author}`, ['name'], name);
/** /**
* Returns an array of all pads this author contributed to * Returns an array of all pads this author contributed to
* @param {String} authorID The id of the author * @param {String} authorID The id of the author
*/ */
exports.listPadsOfAuthor = async (authorID: string) => { export const listPadsOfAuthor = async (authorID: string) => {
/* There are two other places where this array is manipulated: /* There are two other places where this array is manipulated:
* (1) When the author is added to a pad, the author object is also updated * (1) When the author is added to a pad, the author object is also updated
* (2) When a pad is deleted, each author of that pad is also updated * (2) When a pad is deleted, each author of that pad is also updated
*/ */
// get the globalAuthor // get the globalAuthor
const author = await db.get(`globalAuthor:${authorID}`); const author = await get(`globalAuthor:${authorID}`);
if (author == null) { if (author == null) {
// author does not exist // author does not exist
@ -275,9 +277,9 @@ exports.listPadsOfAuthor = async (authorID: string) => {
* @param {String} authorID The id of the author * @param {String} authorID The id of the author
* @param {String} padID The id of the pad the author contributes to * @param {String} padID The id of the pad the author contributes to
*/ */
exports.addPad = async (authorID: string, padID: string) => { export const addPad = async (authorID: string, padID: string) => {
// get the entry // get the entry
const author = await db.get(`globalAuthor:${authorID}`); const author = await get(`globalAuthor:${authorID}`);
if (author == null) return; if (author == null) return;
@ -294,7 +296,7 @@ exports.addPad = async (authorID: string, padID: string) => {
author.padIDs[padID] = 1; // anything, because value is not used author.padIDs[padID] = 1; // anything, because value is not used
// save the new element back // save the new element back
await db.set(`globalAuthor:${authorID}`, author); await set(`globalAuthor:${authorID}`, author);
}; };
/** /**
@ -302,14 +304,14 @@ exports.addPad = async (authorID: string, padID: string) => {
* @param {String} authorID The id of the author * @param {String} authorID The id of the author
* @param {String} padID The id of the pad the author contributes to * @param {String} padID The id of the pad the author contributes to
*/ */
exports.removePad = async (authorID: string, padID: string) => { export const removePad = async (authorID: string, padID?: string) => {
const author = await db.get(`globalAuthor:${authorID}`); const author = await get(`globalAuthor:${authorID}`);
if (author == null) return; if (author == null) return;
if (author.padIDs != null) { if (author.padIDs != null) {
// remove pad from author // remove pad from author
delete author.padIDs[padID]; delete author.padIDs[padID!];
await db.set(`globalAuthor:${authorID}`, author); await set(`globalAuthor:${authorID}`, author);
} }
}; };

View file

@ -24,37 +24,66 @@
import ueberDB from 'ueberdb2'; import ueberDB from 'ueberdb2';
const settings = require('../utils/Settings'); const settings = require('../utils/Settings');
import log4js from 'log4js'; import log4js from 'log4js';
const stats = require('../stats') import {measuredCollection} from '../stats';
const logger = log4js.getLogger('ueberDB'); const logger = log4js.getLogger('ueberDB');
/** /**
* The UeberDB Object that provides the database functions * The UeberDB Object that provides the database functions
*/ */
exports.db = null; let db: ueberDB.Database|null = null;
/** /**
* Initializes the database with the settings provided by the settings module * Initializes the database with the settings provided by the settings module
*/ */
exports.init = async () => { export const init = async () => {
exports.db = new ueberDB.Database(settings.dbType, settings.dbSettings, null, logger); db = new ueberDB.Database(settings.dbType, settings.dbSettings, null, logger);
await exports.db.init(); await db.init();
if (exports.db.metrics != null) { if (db.metrics != null) {
for (const [metric, value] of Object.entries(exports.db.metrics)) { for (const [metric, value] of Object.entries(db.metrics)) {
if (typeof value !== 'number') continue; if (typeof value !== 'number') continue;
stats.gauge(`ueberdb_${metric}`, () => exports.db.metrics[metric]); measuredCollection.gauge(`ueberdb_${metric}`, () => db!.metrics[metric]);
} }
} }
for (const fn of ['get', 'set', 'findKeys', 'getSub', 'setSub', 'remove']) {
const f = exports.db[fn];
exports[fn] = async (...args:string[]) => await f.call(exports.db, ...args);
Object.setPrototypeOf(exports[fn], Object.getPrototypeOf(f));
Object.defineProperties(exports[fn], Object.getOwnPropertyDescriptors(f));
} }
};
exports.shutdown = async (hookName: string, context:any) => { export const get = async (key: string) => {
if (db == null) throw new Error('Database not initialized');
return await db.get(key);
}
export const set = async (key: string, value: any) => {
if (db == null) throw new Error('Database not initialized');
return await db.set(key, value);
}
export const findKeys = async (key: string, notKey:string|null, callback?: Function) => {
if (db == null) throw new Error('Database not initialized');
// @ts-ignore
return await db.findKeys(key, notKey, callback as any);
}
export const getSub = async (key: string, field: string[], callback?: Function) => {
if (db == null) throw new Error('Database not initialized');
// @ts-ignore
return await db.getSub(key, field, callback as any);
}
export const setSub = async (key: string, field: string[], value: any, callback?: any, deprecated?: any) => {
if (db == null) throw new Error('Database not initialized');
// @ts-ignore
return await db.setSub(key, field, value, callback, deprecated);
}
export const remove = async (key: string, callback?: null) => {
if (db == null) throw new Error('Database not initialized');
return await db.remove(key, callback);
}
export const shutdown = async (hookName: string, context:any) => {
if (exports.db != null) await exports.db.close(); if (exports.db != null) await exports.db.close();
exports.db = null; exports.db = null;
logger.log('Database closed'); logger.log('Database closed');
}; };

View file

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

View file

@ -8,23 +8,26 @@ import {MapArrayType} from "../types/MapType";
*/ */
import AttributeMap from '../../static/js/AttributeMap'; import AttributeMap from '../../static/js/AttributeMap';
const Changeset = require('../../static/js/Changeset'); import {applyToAText, checkRep, copyAText, deserializeOps, makeAText, makeSplice, opsFromAText, pack, unpack} from '../../static/js/Changeset';
const ChatMessage = require('../../static/js/ChatMessage'); import ChatMessage from '../../static/js/ChatMessage';
import AttributePool from '../../static/js/AttributePool'; import AttributePool from '../../static/js/AttributePool';
const Stream = require('../utils/Stream'); import Stream from '../utils/Stream';
const assert = require('assert').strict; const assert = require('assert').strict;
const db = require('./DB'); import {get, set, setSub, remove} from './DB';
const settings = require('../utils/Settings'); const settings = require('../utils/Settings');
const authorManager = require('./AuthorManager'); import {addPad, getAuthorColorId, getAuthorName, getColorPalette, removePad} from './AuthorManager';
const padManager = require('./PadManager'); import {doesPadExist, getPad} from './PadManager';
const padMessageHandler = require('../handler/PadMessageHandler'); import {kickSessionsFromPad} from '../handler/PadMessageHandler';
const groupManager = require('./GroupManager'); import {doesGroupExist} from './GroupManager';
const CustomError = require('../utils/customError'); import CustomError from '../utils/customError';
const readOnlyManager = require('./ReadOnlyManager'); import {getReadOnlyId} from './ReadOnlyManager';
const randomString = require('../utils/randomstring'); import {randomString} from '../utils/randomstring';
const hooks = require('../../static/js/pluginfw/hooks'); import {aCallAll} from '../../static/js/pluginfw/hooks';
import {padUtils} from "../../static/js/pad_utils"; import {padUtils} from "../../static/js/pad_utils";
const promises = require('../utils/promises'); import {PadRevision} from "../../static/js/types/PadRevision";
import {} from '../utils/promises';
import {SmartOpAssembler} from "../../static/js/SmartOpAssembler";
import {timesLimit} from "async";
/** /**
* Copied from the Etherpad source code. It converts Windows line breaks to Unix * Copied from the Etherpad source code. It converts Windows line breaks to Unix
@ -32,19 +35,18 @@ const promises = require('../utils/promises');
* @param {String} txt The text to clean * @param {String} txt The text to clean
* @returns {String} The cleaned text * @returns {String} The cleaned text
*/ */
exports.cleanText = (txt:string): string => txt.replace(/\r\n/g, '\n') export const cleanText = (txt:string): string => txt.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n') .replace(/\r/g, '\n')
.replace(/\t/g, ' ') .replace(/\t/g, ' ')
.replace(/\xa0/g, ' '); .replace(/\xa0/g, ' ');
class Pad { class Pad {
private db: Database; atext: AText;
private atext: AText; pool: AttributePool;
private pool: AttributePool; head: number;
private head: number; chatHead: number;
private chatHead: number;
private publicStatus: boolean; private publicStatus: boolean;
private id: string; id: string;
private savedRevisions: any[]; private savedRevisions: any[];
/** /**
* @param id * @param id
@ -54,9 +56,8 @@ class Pad {
* can be used to shard pad storage across multiple database backends, to put each pad in its * 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. * own database table, or to validate imported pad data before it is written to the database.
*/ */
constructor(id:string, database = db) { constructor(id:string) {
this.db = database; this.atext = makeAText('\n');
this.atext = Changeset.makeAText('\n');
this.pool = new AttributePool(); this.pool = new AttributePool();
this.head = -1; this.head = -1;
this.chatHead = -1; this.chatHead = -1;
@ -93,13 +94,13 @@ class Pad {
* @param {String} authorId The id of the author * @param {String} authorId The id of the author
* @return {Promise<number|string>} * @return {Promise<number|string>}
*/ */
async appendRevision(aChangeset:AChangeSet, authorId = '') { async appendRevision(aChangeset:string, authorId: string = ''): Promise<number | string> {
const newAText = Changeset.applyToAText(aChangeset, this.atext, this.pool); const newAText = applyToAText(aChangeset, this.atext, this.pool);
if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs && if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs &&
this.head !== -1) { this.head !== -1) {
return this.head; return this.head;
} }
Changeset.copyAText(newAText, this.atext); copyAText(newAText, this.atext);
const newRev = ++this.head; const newRev = ++this.head;
@ -121,16 +122,17 @@ class Pad {
}, },
}), }),
this.saveToDatabase(), this.saveToDatabase(),
authorId && authorManager.addPad(authorId, this.id), authorId && addPad(authorId, this.id),
hooks.aCallAll(hook, { aCallAll(hook, {
pad: this, pad: this,
authorId, authorId,
get author() { get author() {
padUtils.warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`); padUtils.warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`);
return this.authorId; return authorId;
}, },
set author(authorId) { set author(authorId) {
padUtils.warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`); padUtils.warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`);
// @ts-ignore
this.authorId = authorId; this.authorId = authorId;
}, },
...this.head === 0 ? {} : { ...this.head === 0 ? {} : {
@ -192,8 +194,8 @@ class Pad {
* Returns all authors that worked on this pad * Returns all authors that worked on this pad
* @return {[String]} The id of authors who contributed to this pad * @return {[String]} The id of authors who contributed to this pad
*/ */
getAllAuthors() { getAllAuthors(): string[] {
const authorIds = []; const authorIds: string[] = [];
for (const key in this.pool.numToAttrib) { 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] !== '') {
@ -215,22 +217,23 @@ class Pad {
]); ]);
const apool = this.apool(); const apool = this.apool();
let atext = keyAText; let atext = keyAText;
for (const cs of changesets) atext = Changeset.applyToAText(cs, atext, apool); for (const cs of changesets) atext = applyToAText(cs, atext, apool);
return atext; return atext;
} }
async getRevision(revNum: number) { async getRevision(revNum: number) {
return await this.db.get(`pad:${this.id}:revs:${revNum}`); return await get(`pad:${this.id}:revs:${revNum}`);
} }
async getAllAuthorColors() { async getAllAuthorColors() {
const authorIds = this.getAllAuthors(); const authorIds = this.getAllAuthors();
const returnTable:MapArrayType<string> = {}; const returnTable:MapArrayType<number> = {};
const colorPalette = authorManager.getColorPalette(); const colorPalette = getColorPalette();
await Promise.all( await Promise.all(
authorIds.map((authorId) => authorManager.getAuthorColorId(authorId).then((colorId:string) => { authorIds.map((authorId) => getAuthorColorId(authorId).then((colorId) => {
// colorId might be a hex color or an number out of the palette // colorId might be a hex color or an number out of the palette
// @ts-ignore
returnTable[authorId] = colorPalette[colorId] || colorId; returnTable[authorId] = colorPalette[colorId] || colorId;
}))); })));
@ -293,7 +296,7 @@ class Pad {
(!ins && start > 0 && orig[start - 1] === '\n'); (!ins && start > 0 && orig[start - 1] === '\n');
if (!willEndWithNewline) ins += '\n'; if (!willEndWithNewline) ins += '\n';
if (ndel === 0 && ins.length === 0) return; if (ndel === 0 && ins.length === 0) return;
const changeset = Changeset.makeSplice(orig, start, ndel, ins); const changeset = makeSplice(orig, start, ndel, ins);
await this.appendRevision(changeset, authorId); await this.appendRevision(changeset, authorId);
} }
@ -316,7 +319,7 @@ class Pad {
* @param {string} [authorId] - The author ID of the user that initiated the change, if * @param {string} [authorId] - The author ID of the user that initiated the change, if
* applicable. * applicable.
*/ */
async appendText(newText:string, authorId = '') { async appendText(newText:string, authorId: string = '') {
await this.spliceText(this.text().length - 1, 0, newText, authorId); await this.spliceText(this.text().length - 1, 0, newText, authorId);
} }
@ -330,7 +333,7 @@ class Pad {
* @param {?number} [time] - Message timestamp (milliseconds since epoch). Deprecated; use * @param {?number} [time] - Message timestamp (milliseconds since epoch). Deprecated; use
* `msgOrText.time` instead. * `msgOrText.time` instead.
*/ */
async appendChatMessage(msgOrText: string|typeof ChatMessage, authorId = null, time = null) { async appendChatMessage(msgOrText: string| ChatMessage, authorId = null, time = null) {
const msg = const msg =
msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time); msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time);
this.chatHead++; this.chatHead++;
@ -338,6 +341,7 @@ class Pad {
// Don't save the display name in the database because the user can change it at any time. The // 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 // `displayName` property will be populated with the current value when the message is read
// from the database. // from the database.
// @ts-ignore
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(), this.saveToDatabase(),
]); ]);
@ -347,11 +351,11 @@ class Pad {
* @param {number} entryNum - ID of the desired chat message. * @param {number} entryNum - ID of the desired chat message.
* @returns {?ChatMessage} * @returns {?ChatMessage}
*/ */
async getChatMessage(entryNum: number) { async getChatMessage(entryNum: number): Promise<ChatMessage | null> {
const entry = await this.db.get(`pad:${this.id}:chat:${entryNum}`); const entry = await get(`pad:${this.id}:chat:${entryNum}`);
if (entry == null) return null; if (entry == null) return null;
const message = ChatMessage.fromObject(entry); const message = ChatMessage.fromObject(entry);
message.displayName = await authorManager.getAuthorName(message.authorId); message.displayName = await getAuthorName(message.authorId!);
return message; return message;
} }
@ -362,7 +366,7 @@ class Pad {
* (inclusive), in order. Note: `start` and `end` form a closed interval, not a half-open * (inclusive), in order. Note: `start` and `end` form a closed interval, not a half-open
* interval as is typical in code. * interval as is typical in code.
*/ */
async getChatMessages(start: string, end: number) { async getChatMessages(start: number, end: number) {
const entries = const entries =
await Promise.all(Stream.range(start, end + 1).map(this.getChatMessage.bind(this))); await Promise.all(Stream.range(start, end + 1).map(this.getChatMessage.bind(this)));
@ -378,9 +382,9 @@ class Pad {
}); });
} }
async init(text:string, authorId = '') { async init(text:string|null, authorId = '') {
// try to load the pad // try to load the pad
const value = await this.db.get(`pad:${this.id}`); const value = await get(`pad:${this.id}`);
// if this pad exists, load it // if this pad exists, load it
if (value != null) { if (value != null) {
@ -389,14 +393,14 @@ class Pad {
} else { } else {
if (text == null) { if (text == null) {
const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText}; const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText};
await hooks.aCallAll('padDefaultContent', context); await aCallAll('padDefaultContent', context);
if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`); if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`);
text = exports.cleanText(context.content); text = exports.cleanText(context.content);
} }
const firstChangeset = Changeset.makeSplice('\n', 0, 0, text); const firstChangeset = makeSplice('\n', 0, 0, text);
await this.appendRevision(firstChangeset, authorId); await this.appendRevision(firstChangeset, authorId);
} }
await hooks.aCallAll('padLoad', {pad: this}); await aCallAll('padLoad', {pad: this});
} }
async copy(destinationID: string, force: boolean) { async copy(destinationID: string, force: boolean) {
@ -415,8 +419,8 @@ class Pad {
await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force); await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force);
const copyRecord = async (keySuffix: string) => { const copyRecord = async (keySuffix: string) => {
const val = await this.db.get(`pad:${this.id}${keySuffix}`); const val = await get(`pad:${this.id}${keySuffix}`);
await db.set(`pad:${destinationID}${keySuffix}`, val); await set(`pad:${destinationID}${keySuffix}`, val);
}; };
const promises = (function* () { const promises = (function* () {
@ -427,22 +431,24 @@ class Pad {
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 // @ts-ignore
yield this.copyAuthorInfoToDestinationPad(destinationID); yield this.copyAuthorInfoToDestinationPad(destinationID);
if (destGroupID) yield db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1); if (destGroupID) yield setSub(`group:${destGroupID}`, ['pads', destinationID], 1);
}).call(this); }).call(this);
for (const p of new Stream(promises).batch(100).buffer(99)) await p; for (const p of new Stream(promises).batch(100).buffer(99)) await p;
// Initialize the new pad (will update the listAllPads cache) // Initialize the new pad (will update the listAllPads cache)
const dstPad = await padManager.getPad(destinationID, null); const dstPad = await getPad(destinationID, null);
// let the plugins know the pad was copied // let the plugins know the pad was copied
await hooks.aCallAll('padCopy', { await aCallAll('padCopy', {
get originalPad() { get originalPad() {
padUtils.warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead'); padUtils.warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead');
// @ts-ignore
return this.srcPad; return this.srcPad;
}, },
get destinationID() { get destinationID() {
padUtils.warnDeprecated( padUtils.warnDeprecated(
'padCopy destinationID context property is deprecated; use dstPad.id instead'); 'padCopy destinationID context property is deprecated; use dstPad.id instead');
// @ts-ignore
return this.dstPad.id; return this.dstPad.id;
}, },
srcPad: this, srcPad: this,
@ -457,7 +463,7 @@ class Pad {
if (destinationID.indexOf('$') >= 0) { if (destinationID.indexOf('$') >= 0) {
destGroupID = destinationID.split('$')[0]; destGroupID = destinationID.split('$')[0];
const groupExists = await groupManager.doesGroupExist(destGroupID); const groupExists = await doesGroupExist(destGroupID);
// group does not exist // group does not exist
if (!groupExists) { if (!groupExists) {
@ -469,7 +475,7 @@ class Pad {
async removePadIfForceIsTrueAndAlreadyExist(destinationID: string, force: boolean|string) { async removePadIfForceIsTrueAndAlreadyExist(destinationID: string, force: boolean|string) {
// if the pad exists, we should abort, unless forced. // if the pad exists, we should abort, unless forced.
const exists = await padManager.doesPadExist(destinationID); const exists = await doesPadExist(destinationID);
// allow force to be a string // allow force to be a string
if (typeof force === 'string') { if (typeof force === 'string') {
@ -485,7 +491,7 @@ class Pad {
} }
// exists and forcing // exists and forcing
const pad = await padManager.getPad(destinationID); const pad = await getPad(destinationID);
await pad.remove(); await pad.remove();
} }
} }
@ -493,7 +499,7 @@ class Pad {
async copyAuthorInfoToDestinationPad(destinationID: string) { async copyAuthorInfoToDestinationPad(destinationID: string) {
// add the new sourcePad to all authors who contributed to the old one // add the new sourcePad to all authors who contributed to the old one
await Promise.all(this.getAllAuthors().map( await Promise.all(this.getAllAuthors().map(
(authorID) => authorManager.addPad(authorID, destinationID))); (authorID) => addPad(authorID, destinationID)));
} }
async copyPadWithoutHistory(destinationID: string, force: string|boolean, authorId = '') { async copyPadWithoutHistory(destinationID: string, force: string|boolean, authorId = '') {
@ -510,18 +516,18 @@ class Pad {
// Group pad? Add it to the group's list // Group pad? Add it to the group's list
if (destGroupID) { if (destGroupID) {
await db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1); await setSub(`group:${destGroupID}`, ['pads', destinationID], 1);
} }
// initialize the pad with a new line to avoid getting the defaultText // initialize the pad with a new line to avoid getting the defaultText
const dstPad = await padManager.getPad(destinationID, '\n', authorId); const dstPad = await getPad(destinationID, '\n', authorId);
dstPad.pool = this.pool.clone(); dstPad.pool = this.pool.clone();
const oldAText = this.atext; const oldAText = this.atext;
// based on Changeset.makeSplice // based on Changeset.makeSplice
const assem = Changeset.smartOpAssembler(); const assem = new SmartOpAssembler();
for (const op of Changeset.opsFromAText(oldAText)) assem.append(op); for (const op of opsFromAText(oldAText)) assem.append(op);
assem.endDocument(); assem.endDocument();
// although we have instantiated the dstPad with '\n', an additional '\n' is // although we have instantiated the dstPad with '\n', an additional '\n' is
@ -533,17 +539,19 @@ class Pad {
// create a changeset that removes the previous text and add the newText with // create a changeset that removes the previous text and add the newText with
// all atributes present on the source pad // all atributes present on the source pad
const changeset = Changeset.pack(oldLength, newLength, assem.toString(), newText); const changeset = pack(oldLength, newLength, assem.toString(), newText);
dstPad.appendRevision(changeset, authorId); await dstPad.appendRevision(changeset, authorId);
await hooks.aCallAll('padCopy', { await aCallAll('padCopy', {
get originalPad() { get originalPad() {
padUtils.warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead'); padUtils.warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead');
// @ts-ignore
return this.srcPad; return this.srcPad;
}, },
get destinationID() { get destinationID() {
padUtils.warnDeprecated( padUtils.warnDeprecated(
'padCopy destinationID context property is deprecated; use dstPad.id instead'); 'padCopy destinationID context property is deprecated; use dstPad.id instead');
// @ts-ignore
return this.dstPad.id; return this.dstPad.id;
}, },
srcPad: this, srcPad: this,
@ -558,7 +566,7 @@ class Pad {
const p = []; const p = [];
// kick everyone from this pad // kick everyone from this pad
padMessageHandler.kickSessionsFromPad(padID); kickSessionsFromPad(padID);
// delete all relations - the original code used async.parallel but // delete all relations - the original code used async.parallel but
// none of the operations except getting the group depended on callbacks // none of the operations except getting the group depended on callbacks
@ -569,41 +577,44 @@ class Pad {
if (padID.indexOf('$') >= 0) { if (padID.indexOf('$') >= 0) {
// it is a group pad // 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}`); const group = await get(`group:${groupID}`);
// remove the pad entry // remove the pad entry
delete group.pads[padID]; delete group.pads[padID];
// set the new value // set the new value
p.push(db.set(`group:${groupID}`, group)); p.push(set(`group:${groupID}`, group));
} }
// remove the readonly entries // remove the readonly entries
p.push(readOnlyManager.getReadOnlyId(padID).then(async (readonlyID: string) => { p.push(getReadOnlyId(padID).then(async (readonlyID: string) => {
await db.remove(`readonly2pad:${readonlyID}`); await remove(`readonly2pad:${readonlyID}`);
})); }));
p.push(db.remove(`pad2readonly:${padID}`)); p.push(remove(`pad2readonly:${padID}`));
// delete all chat messages // delete all chat messages
p.push(promises.timesLimit(this.chatHead + 1, 500, async (i: string) => { // @ts-ignore
await this.db.remove(`pad:${this.id}:chat:${i}`, null); p.push(timesLimit(this.chatHead + 1, 500, async (i: string) => {
await remove(`pad:${this.id}:chat:${i}`, null);
})); }));
// delete all revisions // delete all revisions
p.push(promises.timesLimit(this.head + 1, 500, async (i: string) => { // @ts-ignore
await this.db.remove(`pad:${this.id}:revs:${i}`, null); p.push(timesLimit(this.head + 1, 500, async (i: string) => {
await remove(`pad:${this.id}:revs:${i}`, null);
})); }));
// remove pad from all authors who contributed // remove pad from all authors who contributed
this.getAllAuthors().forEach((authorId) => { this.getAllAuthors().forEach((authorId) => {
p.push(authorManager.removePad(authorId, padID)); p.push(removePad(authorId, padID));
}); });
// delete the pad entry and delete pad from padManager // delete the pad entry and delete pad from padManager
p.push(padManager.removePad(padID)); p.push(removePad(padID));
p.push(hooks.aCallAll('padRemove', { p.push(aCallAll('padRemove', {
get padID() { get padID() {
padUtils.warnDeprecated('padRemove padID context property is deprecated; use pad.id instead'); padUtils.warnDeprecated('padRemove padID context property is deprecated; use pad.id instead');
// @ts-ignore
return this.pad.id; return this.pad.id;
}, },
pad: this, pad: this,
@ -617,7 +628,7 @@ class Pad {
await this.saveToDatabase(); await this.saveToDatabase();
} }
async addSavedRevision(revNum: string, savedById: string, label: string) { async addSavedRevision(revNum: number, savedById: string, label?: string) {
// if this revision is already saved, return silently // if this revision is already saved, return silently
for (const i in this.savedRevisions) { for (const i in this.savedRevisions) {
if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) { if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) {
@ -626,12 +637,13 @@ class Pad {
} }
// build the saved revision object // build the saved revision object
const savedRevision:MapArrayType<any> = {}; const savedRevision:PadRevision = {
savedRevision.revNum = revNum; revNum,
savedRevision.savedById = savedById; savedById,
savedRevision.label = label || `Revision ${revNum}`; label: label || `Revision ${revNum}`,
savedRevision.timestamp = Date.now(); timestamp: Date.now(),
savedRevision.id = randomString(10); id: randomString(10),
};
// save this new saved revision // save this new saved revision
this.savedRevisions.push(savedRevision); this.savedRevisions.push(savedRevision);
@ -706,7 +718,7 @@ class Pad {
} }
}) })
.batch(100).buffer(99); .batch(100).buffer(99);
let atext = Changeset.makeAText('\n'); let atext = makeAText('\n');
for await (const [r, changeset, authorId, timestamp, isKeyRev, keyAText] of revs) { for await (const [r, changeset, authorId, timestamp, isKeyRev, keyAText] of revs) {
try { try {
assert(authorId != null); assert(authorId != null);
@ -717,10 +729,10 @@ class Pad {
assert(timestamp > 0); assert(timestamp > 0);
assert(changeset != null); assert(changeset != null);
assert.equal(typeof changeset, 'string'); assert.equal(typeof changeset, 'string');
Changeset.checkRep(changeset); checkRep(changeset);
const unpacked = Changeset.unpack(changeset); const unpacked = unpack(changeset);
let text = atext.text; let text = atext.text;
for (const op of Changeset.deserializeOps(unpacked.ops)) { for (const op of deserializeOps(unpacked.ops)) {
if (['=', '-'].includes(op.opcode)) { if (['=', '-'].includes(op.opcode)) {
assert(text.length >= op.chars); assert(text.length >= op.chars);
const consumed = text.slice(0, op.chars); const consumed = text.slice(0, op.chars);
@ -731,7 +743,7 @@ class Pad {
} }
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); atext = applyToAText(changeset, atext, pool);
if (isKeyRev) assert.deepEqual(keyAText, atext); if (isKeyRev) assert.deepEqual(keyAText, atext);
} catch (err:any) { } catch (err:any) {
err.message = `(pad ${this.id} revision ${r}) ${err.message}`; err.message = `(pad ${this.id} revision ${r}) ${err.message}`;
@ -759,7 +771,7 @@ class Pad {
.batch(100).buffer(99); .batch(100).buffer(99);
for (const p of chats) await p; for (const p of chats) await p;
await hooks.aCallAll('padCheck', {pad: this}); await aCallAll('padCheck', {pad: this});
} }
} }
exports.Pad = Pad; export default Pad

View file

@ -20,11 +20,9 @@
*/ */
import {MapArrayType} from "../types/MapType"; import {MapArrayType} from "../types/MapType";
import {PadType} from "../types/PadType"; import CustomError from '../utils/customError';
import Pad from '../db/Pad';
const CustomError = require('../utils/customError'); import {findKeys, get, remove} from './DB';
const Pad = require('../db/Pad');
const db = require('./DB');
const settings = require('../utils/Settings'); const settings = require('../utils/Settings');
/** /**
@ -74,7 +72,7 @@ const padList = new class {
async getPads() { async getPads() {
if (!this._loaded) { if (!this._loaded) {
this._loaded = (async () => { this._loaded = (async () => {
const dbData = await db.findKeys('pad:*', '*:*:*'); const dbData = await findKeys('pad:*', '*:*:*');
if (dbData == null) return; if (dbData == null) return;
for (const val of dbData) this.addPad(val.replace(/^pad:/, '')); for (const val of dbData) this.addPad(val.replace(/^pad:/, ''));
})(); })();
@ -106,9 +104,9 @@ const padList = new class {
* @param {string} [authorId] - Optional author ID of the user that initiated the pad creation (if * @param {string} [authorId] - Optional author ID of the user that initiated the pad creation (if
* applicable). * applicable).
*/ */
exports.getPad = async (id: string, text?: string|null, authorId:string|null = ''):Promise<PadType> => { export const getPad = async (id: string, text?: string|null, authorId:string|null = ''):Promise<Pad> => {
// check if this is a valid padId // check if this is a valid padId
if (!exports.isValidPadId(id)) { if (!isValidPadId(id)) {
throw new CustomError(`${id} is not a valid padId`, 'apierror'); throw new CustomError(`${id} is not a valid padId`, 'apierror');
} }
@ -133,7 +131,7 @@ exports.getPad = async (id: string, text?: string|null, authorId:string|null = '
} }
// try to load pad // try to load pad
pad = new Pad.Pad(id); pad = new Pad(id);
// initialize the pad // initialize the pad
await pad.init(text, authorId); await pad.init(text, authorId);
@ -143,7 +141,7 @@ exports.getPad = async (id: string, text?: string|null, authorId:string|null = '
return pad; return pad;
}; };
exports.listAllPads = async () => { export const listAllPads = async () => {
const padIDs = await padList.getPads(); const padIDs = await padList.getPads();
return {padIDs}; return {padIDs};
@ -153,14 +151,13 @@ exports.listAllPads = async () => {
// checks if a pad exists // checks if a pad exists
exports.doesPadExist = async (padId: string) => { // alias for backwards compatibility
const value = await db.get(`pad:${padId}`); export const doesPadExist = async (padId: string) => {
const value = await get(`pad:${padId}`);
return (value != null && value.atext); return (value != null && value.atext);
}; };
// alias for backwards compatibility
exports.doesPadExists = exports.doesPadExist;
/** /**
* An array of padId transformations. These represent changes in pad name policy over * An array of padId transformations. These represent changes in pad name policy over
@ -172,9 +169,9 @@ const padIdTransforms = [
]; ];
// returns a sanitized padId, respecting legacy pad id formats // returns a sanitized padId, respecting legacy pad id formats
exports.sanitizePadId = async (padId: string) => { export const sanitizePadId = async (padId: string) => {
for (let i = 0, n = padIdTransforms.length; i < n; ++i) { for (let i = 0, n = padIdTransforms.length; i < n; ++i) {
const exists = await exports.doesPadExist(padId); const exists = await doesPadExist(padId);
if (exists) { if (exists) {
return padId; return padId;
@ -192,19 +189,19 @@ exports.sanitizePadId = async (padId: string) => {
return padId; return padId;
}; };
exports.isValidPadId = (padId: string) => /^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId); export const isValidPadId = (padId: string) => /^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId);
/** /**
* Removes the pad from database and unloads it. * Removes the pad from database and unloads it.
*/ */
exports.removePad = async (padId: string) => { export const removePad = async (padId: string) => {
const p = db.remove(`pad:${padId}`); const p = await remove(`pad:${padId}`);
exports.unloadPad(padId); unloadPad(padId);
padList.removePad(padId); padList.removePad(padId);
await p; await p;
}; };
// removes a pad from the cache // removes a pad from the cache
exports.unloadPad = (padId: string) => { export const unloadPad = (padId: string) => {
globalPads.remove(padId); globalPads.remove(padId);
}; };

View file

@ -20,8 +20,8 @@
*/ */
const db = require('./DB'); import {get, set} from './DB';
const randomString = require('../utils/randomstring'); import {randomString} from '../utils/randomstring';
/** /**
@ -29,23 +29,23 @@ const randomString = require('../utils/randomstring');
* @param {String} id the pad's id * @param {String} id the pad's id
* @return {Boolean} true if the id is readonly * @return {Boolean} true if the id is readonly
*/ */
exports.isReadOnlyId = (id:string) => id.startsWith('r.'); export const isReadOnlyId = (id:string): boolean => id.startsWith('r.');
/** /**
* returns a read only id for a pad * returns a read only id for a pad
* @param {String} padId the id of the pad * @param {String} padId the id of the pad
* @return {String} the read only id * @return {String} the read only id
*/ */
exports.getReadOnlyId = async (padId:string) => { export const getReadOnlyId = async (padId:string): Promise<string> => {
// check if there is a pad2readonly entry // check if there is a pad2readonly entry
let readOnlyId = await db.get(`pad2readonly:${padId}`); let readOnlyId = await get(`pad2readonly:${padId}`);
// there is no readOnly Entry in the database, let's create one // there is no readOnly Entry in the database, let's create one
if (readOnlyId == null) { if (readOnlyId == null) {
readOnlyId = `r.${randomString(16)}`; readOnlyId = `r.${randomString(16)}`;
await Promise.all([ await Promise.all([
db.set(`pad2readonly:${padId}`, readOnlyId), set(`pad2readonly:${padId}`, readOnlyId),
db.set(`readonly2pad:${readOnlyId}`, padId), set(`readonly2pad:${readOnlyId}`, padId),
]); ]);
} }
@ -57,19 +57,23 @@ exports.getReadOnlyId = async (padId:string) => {
* @param {String} readOnlyId read only id * @param {String} readOnlyId read only id
* @return {String} the padId * @return {String} the padId
*/ */
exports.getPadId = async (readOnlyId:string) => await db.get(`readonly2pad:${readOnlyId}`); export const getPadId = async (readOnlyId:string): Promise<string> => await get(`readonly2pad:${readOnlyId}`) as string;
/** /**
* returns the padId and readonlyPadId in an object for any id * returns the padId and readonlyPadId in an object for any id
* @param {String} id read only id or real pad id * @param {String} id read only id or real pad id
* @return {Object} an object with the padId and readonlyPadId * @return {Object} an object with the padId and readonlyPadId
*/ */
exports.getIds = async (id:string) => { export const getIds = async (id:string): Promise<{
const readonly = exports.isReadOnlyId(id); readOnlyPadId: string,
padId: string,
readonly: boolean
}> => {
const readonly = isReadOnlyId(id);
// Might be null, if this is an unknown read-only id // Might be null, if this is an unknown read-only id
const readOnlyPadId = readonly ? id : await exports.getReadOnlyId(id); const readOnlyPadId = readonly ? id : await getReadOnlyId(id);
const padId = readonly ? await exports.getPadId(id) : id; const padId = readonly ? await getPadId(id) : id;
return {readOnlyPadId, padId, readonly}; return {readOnlyPadId, padId, readonly};
}; };

View file

@ -21,13 +21,13 @@
import {UserSettingsObject} from "../types/UserSettingsObject"; import {UserSettingsObject} from "../types/UserSettingsObject";
const authorManager = require('./AuthorManager'); import {getAuthorId} from './AuthorManager';
const hooks = require('../../static/js/pluginfw/hooks.js'); import {callAll} from '../../static/js/pluginfw/hooks.js';
const padManager = require('./PadManager'); import {doesPadExist, getPad} from './PadManager';
const readOnlyManager = require('./ReadOnlyManager'); import {getPadId, isReadOnlyId} from './ReadOnlyManager';
const sessionManager = require('./SessionManager'); import {findAuthorID} from './SessionManager';
const settings = require('../utils/Settings'); const settings = require('../utils/Settings');
const webaccess = require('../hooks/express/webaccess'); import {normalizeAuthzLevel} from '../hooks/express/webaccess';
const log4js = require('log4js'); const log4js = require('log4js');
const authLogger = log4js.getLogger('auth'); const authLogger = log4js.getLogger('auth');
import {padUtils as padutils} from '../../static/js/pad_utils'; import {padUtils as padutils} from '../../static/js/pad_utils';
@ -57,7 +57,14 @@ const DENY = Object.freeze({accessStatus: 'deny'});
* @param {Object} userSettings * @param {Object} userSettings
* @return {DENY|{accessStatus: String, authorID: String}} * @return {DENY|{accessStatus: String, authorID: String}}
*/ */
exports.checkAccess = async (padID:string, sessionCookie:string, token:string, userSettings:UserSettingsObject) => {
type CheckAccessStat = {
accessStatus: string;
authorID?: string;
}
export const checkAccess = async (padID:string, sessionCookie:string, token:string, userSettings:UserSettingsObject): Promise<CheckAccessStat> => {
if (!padID) { if (!padID) {
authLogger.debug('access denied: missing padID'); authLogger.debug('access denied: missing padID');
return DENY; return DENY;
@ -65,9 +72,9 @@ exports.checkAccess = async (padID:string, sessionCookie:string, token:string, u
let canCreate = !settings.editOnly; let canCreate = !settings.editOnly;
if (readOnlyManager.isReadOnlyId(padID)) { if (isReadOnlyId(padID)) {
canCreate = false; canCreate = false;
padID = await readOnlyManager.getPadId(padID); padID = await getPadId(padID);
if (padID == null) { if (padID == null) {
authLogger.debug('access denied: read-only pad ID for a pad that does not exist'); authLogger.debug('access denied: read-only pad ID for a pad that does not exist');
return DENY; return DENY;
@ -88,7 +95,7 @@ exports.checkAccess = async (padID:string, sessionCookie:string, token:string, u
// Note: userSettings.padAuthorizations should still be populated even if // Note: userSettings.padAuthorizations should still be populated even if
// settings.requireAuthorization is false. // settings.requireAuthorization is false.
const padAuthzs = userSettings.padAuthorizations || {}; const padAuthzs = userSettings.padAuthorizations || {};
const level = webaccess.normalizeAuthzLevel(padAuthzs[padID]); const level = normalizeAuthzLevel(padAuthzs[padID]);
if (!level) { if (!level) {
authLogger.debug('access denied: unauthorized'); authLogger.debug('access denied: unauthorized');
return DENY; return DENY;
@ -98,18 +105,18 @@ exports.checkAccess = async (padID:string, sessionCookie:string, token:string, u
// allow plugins to deny access // allow plugins to deny access
const isFalse = (x:boolean) => x === false; const isFalse = (x:boolean) => x === false;
if (hooks.callAll('onAccessCheck', {padID, token, sessionCookie}).some(isFalse)) { if (callAll('onAccessCheck', {padID, token, sessionCookie}).some(isFalse)) {
authLogger.debug('access denied: an onAccessCheck hook function returned false'); authLogger.debug('access denied: an onAccessCheck hook function returned false');
return DENY; return DENY;
} }
const padExists = await padManager.doesPadExist(padID); const padExists = await doesPadExist(padID);
if (!padExists && !canCreate) { 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; return DENY;
} }
const sessionAuthorID = await sessionManager.findAuthorID(padID.split('$')[0], sessionCookie); const sessionAuthorID = await findAuthorID(padID.split('$')[0], sessionCookie);
if (settings.requireSession && !sessionAuthorID) { if (settings.requireSession && !sessionAuthorID) {
authLogger.debug('access denied: HTTP API session is required'); authLogger.debug('access denied: HTTP API session is required');
return DENY; return DENY;
@ -122,7 +129,7 @@ exports.checkAccess = async (padID:string, sessionCookie:string, token:string, u
const grant = { const grant = {
accessStatus: 'grant', accessStatus: 'grant',
authorID: sessionAuthorID || await authorManager.getAuthorId(token, userSettings), authorID: sessionAuthorID || await getAuthorId(token, userSettings),
}; };
if (!padID.includes('$')) { if (!padID.includes('$')) {
@ -139,7 +146,7 @@ exports.checkAccess = async (padID:string, sessionCookie:string, token:string, u
return grant; return grant;
} }
const pad = await padManager.getPad(padID); const pad = await getPad(padID);
if (!pad.getPublicStatus() && sessionAuthorID == null) { if (!pad.getPublicStatus() && sessionAuthorID == null) {
authLogger.debug('access denied: must have an HTTP API session to access private group pads'); authLogger.debug('access denied: must have an HTTP API session to access private group pads');

View file

@ -20,12 +20,25 @@
* limitations under the License. * limitations under the License.
*/ */
const CustomError = require('../utils/customError'); import CustomError from '../utils/customError';
const promises = require('../utils/promises'); import {firstSatisfies} from '../utils/promises';
const randomString = require('../utils/randomstring'); import {randomString} from '../utils/randomstring';
const db = require('./DB'); import {get, remove, set, setSub} from './DB';
const groupManager = require('./GroupManager'); import {doesGroupExist} from './GroupManager';
const authorManager = require('./AuthorManager'); import {doesAuthorExist} from './AuthorManager';
type Session = {
groupID: string;
authorID: string;
validUntil: number;
};
type Author2Sessions = {
sessionIDs: {
[key: string]: Session;
};
}
/** /**
* Finds the author ID for a session with matching ID and group. * Finds the author ID for a session with matching ID and group.
@ -36,7 +49,7 @@ const authorManager = require('./AuthorManager');
* sessionCookie, and is bound to a group with the given ID, then this returns the author ID * sessionCookie, and is bound to a group with the given ID, then this returns the author ID
* bound to the session. Otherwise, returns undefined. * bound to the session. Otherwise, returns undefined.
*/ */
exports.findAuthorID = async (groupID:string, sessionCookie: string) => { export const findAuthorID = async (groupID:string, sessionCookie: string) => {
if (!sessionCookie) return undefined; if (!sessionCookie) return undefined;
/* /*
* Sometimes, RFC 6265-compliant web servers may send back a cookie whose * Sometimes, RFC 6265-compliant web servers may send back a cookie whose
@ -64,7 +77,7 @@ exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
const sessionIDs = sessionCookie.replace(/^"|"$/g, '').split(','); const sessionIDs = sessionCookie.replace(/^"|"$/g, '').split(',');
const sessionInfoPromises = sessionIDs.map(async (id) => { const sessionInfoPromises = sessionIDs.map(async (id) => {
try { try {
return await exports.getSessionInfo(id); return await getSessionInfo(id);
} catch (err:any) { } catch (err:any) {
if (err.message === 'sessionID does not exist') { if (err.message === 'sessionID does not exist') {
console.debug(`SessionManager getAuthorID: no session exists with ID ${id}`); console.debug(`SessionManager getAuthorID: no session exists with ID ${id}`);
@ -79,7 +92,7 @@ exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
groupID: string; groupID: string;
validUntil: number; validUntil: number;
}|null) => (si != null && si.groupID === groupID && now < si.validUntil); }|null) => (si != null && si.groupID === groupID && now < si.validUntil);
const sessionInfo = await promises.firstSatisfies(sessionInfoPromises, isMatch); const sessionInfo = await firstSatisfies(sessionInfoPromises, isMatch) as Session;
if (sessionInfo == null) return undefined; if (sessionInfo == null) return undefined;
return sessionInfo.authorID; return sessionInfo.authorID;
}; };
@ -89,9 +102,9 @@ exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
* @param {String} sessionID The id of the session * @param {String} sessionID The id of the session
* @return {Promise<boolean>} Resolves to true if the session exists * @return {Promise<boolean>} Resolves to true if the session exists
*/ */
exports.doesSessionExist = async (sessionID: string) => { export const doesSessionExist = async (sessionID: string) => {
// check if the database entry of this session exists // check if the database entry of this session exists
const session = await db.get(`session:${sessionID}`); const session = await get(`session:${sessionID}`) as Session;
return (session != null); return (session != null);
}; };
@ -102,15 +115,15 @@ exports.doesSessionExist = async (sessionID: string) => {
* @param {Number} validUntil The unix timestamp when the session should expire * @param {Number} validUntil The unix timestamp when the session should expire
* @return {Promise<{sessionID: string}>} the id of the new session * @return {Promise<{sessionID: string}>} the id of the new session
*/ */
exports.createSession = async (groupID: string, authorID: string, validUntil: number) => { export const createSession = async (groupID: string, authorID: string, validUntil: number) => {
// check if the group exists // check if the group exists
const groupExists = await groupManager.doesGroupExist(groupID); const groupExists = await doesGroupExist(groupID);
if (!groupExists) { if (!groupExists) {
throw new CustomError('groupID does not exist', 'apierror'); throw new CustomError('groupID does not exist', 'apierror');
} }
// check if the author exists // check if the author exists
const authorExists = await authorManager.doesAuthorExist(authorID); const authorExists = await doesAuthorExist(authorID);
if (!authorExists) { if (!authorExists) {
throw new CustomError('authorID does not exist', 'apierror'); throw new CustomError('authorID does not exist', 'apierror');
} }
@ -144,15 +157,15 @@ exports.createSession = async (groupID: string, authorID: string, validUntil: nu
const sessionID = `s.${randomString(16)}`; const sessionID = `s.${randomString(16)}`;
// set the session into the database // set the session into the database
await db.set(`session:${sessionID}`, {groupID, authorID, validUntil}); await set(`session:${sessionID}`, {groupID, authorID, validUntil} satisfies Session);
// Add the session ID to the group2sessions and author2sessions records after creating the session // Add the session ID to the group2sessions and author2sessions records after creating the session
// so that the state is consistent. // so that the state is consistent.
await Promise.all([ await Promise.all([
// UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object // UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object
// property, and writes the result. // property, and writes the result.
db.setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], 1), setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], 1),
db.setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], 1), setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], 1),
]); ]);
return {sessionID}; return {sessionID};
@ -163,9 +176,9 @@ exports.createSession = async (groupID: string, authorID: string, validUntil: nu
* @param {String} sessionID The id of the session * @param {String} sessionID The id of the session
* @return {Promise<Object>} the sessioninfos * @return {Promise<Object>} the sessioninfos
*/ */
exports.getSessionInfo = async (sessionID:string) => { export const getSessionInfo = async (sessionID:string): Promise<Session> => {
// check if the database entry of this session exists // check if the database entry of this session exists
const session = await db.get(`session:${sessionID}`); const session = await get(`session:${sessionID}`) as Session;
if (session == null) { if (session == null) {
// session does not exist // session does not exist
@ -181,9 +194,9 @@ exports.getSessionInfo = async (sessionID:string) => {
* @param {String} sessionID The id of the session * @param {String} sessionID The id of the session
* @return {Promise<void>} Resolves when the session is deleted * @return {Promise<void>} Resolves when the session is deleted
*/ */
exports.deleteSession = async (sessionID:string) => { export const deleteSession = async (sessionID:string): Promise<void> => {
// ensure that the session exists // ensure that the session exists
const session = await db.get(`session:${sessionID}`); const session = await get(`session:${sessionID}`) as Session;
if (session == null) { if (session == null) {
throw new CustomError('sessionID does not exist', 'apierror'); throw new CustomError('sessionID does not exist', 'apierror');
} }
@ -196,13 +209,13 @@ exports.deleteSession = async (sessionID:string) => {
// UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object // UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object
// property, and writes the result. Setting a property to `undefined` deletes that property // property, and writes the result. Setting a property to `undefined` deletes that property
// (JSON.stringify() ignores such properties). // (JSON.stringify() ignores such properties).
db.setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], undefined), setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], undefined),
db.setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], undefined), setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], undefined),
]); ]);
// Delete the session record after updating group2sessions and author2sessions so that the state // Delete the session record after updating group2sessions and author2sessions so that the state
// is consistent. // is consistent.
await db.remove(`session:${sessionID}`); await remove(`session:${sessionID}`);
}; };
/** /**
@ -210,15 +223,14 @@ exports.deleteSession = async (sessionID:string) => {
* @param {String} groupID The id of the group * @param {String} groupID The id of the group
* @return {Promise<Object>} The sessioninfos of all sessions of this group * @return {Promise<Object>} The sessioninfos of all sessions of this group
*/ */
exports.listSessionsOfGroup = async (groupID: string) => { export const listSessionsOfGroup = async (groupID: string) => {
// check that the group exists // check that the group exists
const exists = await groupManager.doesGroupExist(groupID); const exists = await doesGroupExist(groupID);
if (!exists) { if (!exists) {
throw new CustomError('groupID does not exist', 'apierror'); throw new CustomError('groupID does not exist', 'apierror');
} }
const sessions = await listSessionsWithDBKey(`group2sessions:${groupID}`); return await listSessionsWithDBKey(`group2sessions:${groupID}`);
return sessions;
}; };
/** /**
@ -226,9 +238,9 @@ exports.listSessionsOfGroup = async (groupID: string) => {
* @param {String} authorID The id of the author * @param {String} authorID The id of the author
* @return {Promise<Object>} The sessioninfos of all sessions of this author * @return {Promise<Object>} The sessioninfos of all sessions of this author
*/ */
exports.listSessionsOfAuthor = async (authorID: string) => { export const listSessionsOfAuthor = async (authorID: string): Promise<object> => {
// check that the author exists // check that the author exists
const exists = await authorManager.doesAuthorExist(authorID); const exists = await doesAuthorExist(authorID);
if (!exists) { if (!exists) {
throw new CustomError('authorID does not exist', 'apierror'); throw new CustomError('authorID does not exist', 'apierror');
} }
@ -243,15 +255,15 @@ exports.listSessionsOfAuthor = async (authorID: string) => {
* @param {String} dbkey The db key to use to get the sessions * @param {String} dbkey The db key to use to get the sessions
* @return {Promise<*>} * @return {Promise<*>}
*/ */
const listSessionsWithDBKey = async (dbkey: string) => { const listSessionsWithDBKey = async (dbkey: string): Promise<any> => {
// get the group2sessions entry // get the group2sessions entry
const sessionObject = await db.get(dbkey); const sessionObject = await get(dbkey);
const sessions = sessionObject ? sessionObject.sessionIDs : null; const sessions = sessionObject ? sessionObject.sessionIDs : null;
// iterate through the sessions and get the sessioninfos // iterate through the sessions and get the sessioninfos
for (const sessionID of Object.keys(sessions || {})) { for (const sessionID of Object.keys(sessions || {})) {
try { try {
sessions[sessionID] = await exports.getSessionInfo(sessionID); sessions[sessionID] = await getSessionInfo(sessionID);
} catch (err:any) { } catch (err:any) {
if (err.name === 'apierror') { if (err.name === 'apierror') {
console.warn(`Found bad session ${sessionID} in ${dbkey}`); console.warn(`Found bad session ${sessionID} in ${dbkey}`);

View file

@ -1,9 +1,9 @@
'use strict'; 'use strict';
const DB = require('./DB'); import {get, remove, set} from './DB';
const Store = require('@etherpad/express-session').Store; const Store = require('@etherpad/express-session').Store;
const log4js = require('log4js'); import log4js from 'log4js';
const util = require('util'); import util from 'util';
const logger = log4js.getLogger('SessionStore'); const logger = log4js.getLogger('SessionStore');
@ -19,7 +19,7 @@ class SessionStore extends Store {
* Etherpad is restarted. Use `null` to prevent `touch()` from ever updating the record. * Etherpad is restarted. Use `null` to prevent `touch()` from ever updating the record.
* Ignored if the cookie does not expire. * Ignored if the cookie does not expire.
*/ */
constructor(refresh = null) { constructor(refresh: number | null = null) {
super(); super();
this._refresh = refresh; this._refresh = refresh;
// Maps session ID to an object with the following properties: // Maps session ID to an object with the following properties:
@ -65,12 +65,12 @@ class SessionStore extends Store {
} }
async _write(sid: string, sess: any) { async _write(sid: string, sess: any) {
await DB.set(`sessionstorage:${sid}`, sess); await set(`sessionstorage:${sid}`, sess);
} }
async _get(sid: string) { async _get(sid: string) {
logger.debug(`GET ${sid}`); logger.debug(`GET ${sid}`);
const s = await DB.get(`sessionstorage:${sid}`); const s = await get(`sessionstorage:${sid}`);
return await this._updateExpirations(sid, s); return await this._updateExpirations(sid, s);
} }
@ -84,7 +84,7 @@ class SessionStore extends Store {
logger.debug(`DESTROY ${sid}`); logger.debug(`DESTROY ${sid}`);
clearTimeout((this._expirations.get(sid) || {}).timeout); clearTimeout((this._expirations.get(sid) || {}).timeout);
this._expirations.delete(sid); this._expirations.delete(sid);
await DB.remove(`sessionstorage:${sid}`); await remove(`sessionstorage:${sid}`);
} }
// Note: express-session might call touch() before it calls set() for the first time. Ideally this // Note: express-session might call touch() before it calls set() for the first time. Ideally this
@ -111,4 +111,4 @@ for (const m of ['get', 'set', 'destroy', 'touch']) {
SessionStore.prototype[m] = util.callbackify(SessionStore.prototype[`_${m}`]); SessionStore.prototype[m] = util.callbackify(SessionStore.prototype[`_${m}`]);
} }
module.exports = SessionStore; export default SessionStore

View file

@ -20,54 +20,61 @@
* require("./index").require("./path/to/template.ejs") * require("./index").require("./path/to/template.ejs")
*/ */
const ejs = require('ejs'); import ejs from 'ejs';
const fs = require('fs'); import fs from 'fs';
const hooks = require('../../static/js/pluginfw/hooks.js'); import {callAll} from '../../static/js/pluginfw/hooks.js';
const path = require('path'); import path from 'path';
const resolve = require('resolve'); import resolve from 'resolve';
const settings = require('../utils/Settings'); const settings = require('../utils/Settings');
import {pluginInstallPath} from '../../static/js/pluginfw/installer' import {pluginInstallPath} from '../../static/js/pluginfw/installer'
const templateCache = new Map(); const templateCache = new Map();
exports.info = { export const info: {
__output_stack: any[],
block_stack: string[],
file_stack: {path: string}[],
args: any[],
__output: any
} = {
__output_stack: [], __output_stack: [],
block_stack: [], block_stack: [],
file_stack: [], file_stack: [],
args: [], args: [],
__output: null
}; };
const getCurrentFile = () => exports.info.file_stack[exports.info.file_stack.length - 1]; const getCurrentFile = () => info.file_stack[info.file_stack.length - 1];
exports._init = (b: any, recursive: boolean) => { export const _init = (b: any, recursive: boolean) => {
exports.info.__output_stack.push(exports.info.__output); info.__output_stack.push(info.__output);
exports.info.__output = b; info.__output = b;
}; };
exports._exit = (b:any, recursive:boolean) => { export const _exit = (b:any, recursive:boolean) => {
exports.info.__output = exports.info.__output_stack.pop(); info.__output = info.__output_stack.pop();
}; };
exports.begin_block = (name:string) => { export const begin_block = (name:string) => {
exports.info.block_stack.push(name); info.block_stack.push(name);
exports.info.__output_stack.push(exports.info.__output.get()); info.__output_stack.push(info.__output.get());
exports.info.__output.set(''); info.__output.set('');
}; };
exports.end_block = () => { export const end_block = () => {
const name = exports.info.block_stack.pop(); const name = info.block_stack.pop();
const renderContext = exports.info.args[exports.info.args.length - 1]; const renderContext = info.args[info.args.length - 1];
const content = exports.info.__output.get(); const content = info.__output.get();
exports.info.__output.set(exports.info.__output_stack.pop()); info.__output.set(info.__output_stack.pop());
const args = {content, renderContext}; const args = {content, renderContext};
hooks.callAll(`eejsBlock_${name}`, args); callAll(`eejsBlock_${name}`, args);
exports.info.__output.set(exports.info.__output.get().concat(args.content)); info.__output.set(info.__output.get().concat(args.content));
}; };
exports.require = (name:string, args:{ export const requireP = (name:string, args:{
e?: Function, e?: Function,
require?: Function, require?: string,
}, mod:{ }, mod?:{
filename:string, filename:string,
paths:string[], paths:string[],
}) => { }) => {
@ -76,7 +83,7 @@ exports.require = (name:string, args:{
let basedir = __dirname; let basedir = __dirname;
let paths:string[] = []; let paths:string[] = [];
if (exports.info.file_stack.length) { if (info.file_stack.length) {
basedir = path.dirname(getCurrentFile().path); basedir = path.dirname(getCurrentFile().path);
} }
if (mod) { if (mod) {
@ -94,20 +101,21 @@ exports.require = (name:string, args:{
const ejspath = resolve.sync(name, {paths, basedir, extensions: ['.html', '.ejs']}); const ejspath = resolve.sync(name, {paths, basedir, extensions: ['.html', '.ejs']});
args.e = exports; args.e = exports;
args.require = require; // @ts-ignore
args.require = requireP;
const cache = settings.maxAge !== 0; const cache: boolean = settings.maxAge !== 0;
const template = cache && templateCache.get(ejspath) || ejs.compile( const template = cache && templateCache.get(ejspath) || ejs.compile(
'<% e._init({get: () => __output, set: (s) => { __output = s; }}); %>' + '<% e._init({get: () => __output, set: (s) => { __output = s; }}); %>' +
`${fs.readFileSync(ejspath).toString()}<% e._exit(); %>`, `${fs.readFileSync(ejspath).toString()}<% e._exit(); %>`,
{filename: ejspath}); {filename: ejspath});
if (cache) templateCache.set(ejspath, template); if (cache) templateCache.set(ejspath, template);
exports.info.args.push(args); info.args.push(args);
exports.info.file_stack.push({path: ejspath}); info.file_stack.push({path: ejspath});
const res = template(args); const res = template(args);
exports.info.file_stack.pop(); info.file_stack.pop();
exports.info.args.pop(); info.args.pop();
return res; return res;
}; };

View file

@ -22,34 +22,38 @@
import {MapArrayType} from "../types/MapType"; import {MapArrayType} from "../types/MapType";
import AttributeMap from '../../static/js/AttributeMap'; import AttributeMap from '../../static/js/AttributeMap';
const padManager = require('../db/PadManager'); import {doesPadExist, getPad, sanitizePadId} from '../db/PadManager';
const Changeset = require('../../static/js/Changeset'); import {checkRep, cloneAText, compose, deserializeOps, follow, identity, inverse, makeAText, makeSplice, moveOpsToNewPool, mutateTextLines, oldLen, prepareForWire, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset';
const ChatMessage = require('../../static/js/ChatMessage'); import ChatMessage from '../../static/js/ChatMessage';
import AttributePool from '../../static/js/AttributePool'; import AttributePool from '../../static/js/AttributePool';
import AttributeManager from '../../static/js/AttributeManager'; import AttributeManager from '../../static/js/AttributeManager';
const authorManager = require('../db/AuthorManager'); import {getAuthor, getAuthorColorId, getAuthorName, getColorPalette, setAuthorColorId, setAuthorName} from '../db/AuthorManager';
const {padutils} = require('../../static/js/pad_utils'); import {padUtils} from '../../static/js/pad_utils';
const readOnlyManager = require('../db/ReadOnlyManager'); import {getIds} from '../db/ReadOnlyManager';
const settings = require('../utils/Settings'); import settings from '../utils/Settings';
const securityManager = require('../db/SecurityManager'); import {checkAccess} from '../db/SecurityManager';
const plugins = require('../../static/js/pluginfw/plugin_defs.js'); import {pluginDefs} from '../../static/js/pluginfw/plugin_defs.js';
import log4js from 'log4js'; import log4js from 'log4js';
const messageLogger = log4js.getLogger('message'); const messageLogger = log4js.getLogger('message');
const accessLogger = log4js.getLogger('access'); const accessLogger = log4js.getLogger('access');
const hooks = require('../../static/js/pluginfw/hooks.js'); import {aCallAll, deprecationNotices} from '../../static/js/pluginfw/hooks.js';
const stats = require('../stats') import {measuredCollection} from '../stats';
const assert = require('assert').strict; const assert = require('assert').strict;
import {RateLimiterMemory} from 'rate-limiter-flexible'; import {RateLimiterMemory} from 'rate-limiter-flexible';
import {ChangesetRequest, PadUserInfo, SocketClientRequest} from "../types/SocketClientRequest"; import {ChangesetRequest, PadUserInfo, SocketClientRequest} from "../types/SocketClientRequest";
import {APool, AText, PadAuthor, PadType} from "../types/PadType"; import {AText, PadAuthor, PadType} from "../types/PadType";
import {ChangeSet} from "../types/ChangeSet"; import {ChangeSet} from "../types/ChangeSet";
const webaccess = require('../hooks/express/webaccess'); import Pad from "../db/Pad";
const { checkValidRev } = require('../utils/checkValidRev'); import { userCanModify} from '../hooks/express/webaccess';
import {checkValidRev} from '../utils/checkValidRev';
import {AttributePoolWire, ChangesetRequestMessage, ChatMessageMessage, ClientReadyMessage, ClientSendMessages, ClientUserChangesMessage, ClientVarMessage, ClientVarPayload, HistoricalAuthorData, UserChanges, UserSuggestUserName} from "../../static/js/types/SocketIOMessage";
import {AttributionLinesMutator} from "../../static/js/AttributionLinesMutator";
import {Builder} from "../../static/js/Builder";
let rateLimiter:any; let rateLimiter:any;
let socketio: any = null; let _socketio: any = null;
hooks.deprecationNotices.clientReady = 'use the userJoin hook instead'; deprecationNotices.clientReady = 'use the userJoin hook instead';
const addContextToError = (err:any, pfx:string) => { const addContextToError = (err:any, pfx:string) => {
const newErr = new Error(`${pfx}${err.message}`, {cause: err}); const newErr = new Error(`${pfx}${err.message}`, {cause: err});
@ -60,7 +64,7 @@ const addContextToError = (err:any, pfx:string) => {
return err; return err;
}; };
exports.socketio = () => { export const socketio = () => {
// The rate limiter is created in this hook so that restarting the server resets the limiter. The // The rate limiter is created in this hook so that restarting the server resets the limiter. The
// settings.commitRateLimiting object is passed directly to the rate limiter so that the limits // settings.commitRateLimiting object is passed directly to the rate limiter so that the limits
// can be dynamically changed during runtime by modifying its properties. // can be dynamically changed during runtime by modifying its properties.
@ -85,11 +89,10 @@ exports.socketio = () => {
* - readonly: Whether the client has read-only access (true) or read/write access (false). * - readonly: Whether the client has read-only access (true) or read/write access (false).
* - rev: The last revision that was sent to the client. * - rev: The last revision that was sent to the client.
*/ */
const sessioninfos:MapArrayType<any> = {}; export const sessioninfos:MapArrayType<any> = {};
exports.sessioninfos = sessioninfos;
stats.gauge('totalUsers', () => socketio ? socketio.engine.clientsCount : 0); measuredCollection.gauge('totalUsers', () => _socketio ? _socketio.engine.clientsCount : 0);
stats.gauge('activePads', () => { measuredCollection.gauge('activePads', () => {
const padIds = new Set(); const padIds = new Set();
for (const {padId} of Object.values(sessioninfos)) { for (const {padId} of Object.values(sessioninfos)) {
if (!padId) continue; if (!padId) continue;
@ -108,7 +111,7 @@ class Channels {
* @param {(ch, task) => any} [exec] - Task executor. If omitted, tasks are assumed to be * @param {(ch, task) => any} [exec] - Task executor. If omitted, tasks are assumed to be
* functions that will be executed with the channel as the only argument. * functions that will be executed with the channel as the only argument.
*/ */
constructor(exec = (ch: string, task:any) => task(ch)) { constructor(exec: (ch:any, task:any) => any = (ch: string, task:any) => task(ch)) {
this._exec = exec; this._exec = exec;
this._promiseChains = new Map(); this._promiseChains = new Map();
} }
@ -143,16 +146,16 @@ const padChannels = new Channels((ch, {socket, message}) => handleUserChanges(so
* This Method is called by server.ts to tell the message handler on which socket it should send * This Method is called by server.ts to tell the message handler on which socket it should send
* @param socket_io The Socket * @param socket_io The Socket
*/ */
exports.setSocketIO = (socket_io:any) => { export const setSocketIO = (socket_io:any) => {
socketio = socket_io; _socketio = socket_io;
}; };
/** /**
* Handles the connection of a new user * Handles the connection of a new user
* @param socket the socket.io Socket object for the new connection from the client * @param socket the socket.io Socket object for the new connection from the client
*/ */
exports.handleConnect = (socket:any) => { export const handleConnect = (socket:any) => {
stats.meter('connects').mark(); measuredCollection.meter('connects').mark();
// Initialize sessioninfos for this new session // Initialize sessioninfos for this new session
sessioninfos[socket.id] = {}; sessioninfos[socket.id] = {};
@ -161,23 +164,23 @@ exports.handleConnect = (socket:any) => {
/** /**
* Kicks all sessions from a pad * Kicks all sessions from a pad
*/ */
exports.kickSessionsFromPad = (padID: string) => { export const kickSessionsFromPad = (padID: string) => {
if(socketio.sockets == null) return; if(_socketio.sockets == null) return;
// skip if there is nobody on this pad // skip if there is nobody on this pad
if (_getRoomSockets(padID).length === 0) return; if (_getRoomSockets(padID).length === 0) return;
// disconnect everyone from this pad // disconnect everyone from this pad
socketio.in(padID).emit('message', {disconnect: 'deleted'}); _socketio.in(padID).emit('message', {disconnect: 'deleted'});
}; };
/** /**
* Handles the disconnection of a user * Handles the disconnection of a user
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
*/ */
exports.handleDisconnect = async (socket:any) => { export const handleDisconnect = async (socket:any) => {
stats.meter('disconnects').mark(); measuredCollection.meter('disconnects').mark();
const session = sessioninfos[socket.id]; const session = sessioninfos[socket.id];
delete sessioninfos[socket.id]; delete sessioninfos[socket.id];
// session.padId can be nullish if the user disconnects before sending CLIENT_READY. // session.padId can be nullish if the user disconnects before sending CLIENT_READY.
@ -196,12 +199,12 @@ exports.handleDisconnect = async (socket:any) => {
data: { data: {
type: 'USER_LEAVE', type: 'USER_LEAVE',
userInfo: { userInfo: {
colorId: await authorManager.getAuthorColorId(session.author), colorId: await getAuthorColorId(session.author),
userId: session.author, userId: session.author,
}, },
}, },
}); });
await hooks.aCallAll('userLeave', { await aCallAll('userLeave', {
...session, // For backwards compatibility. ...session, // For backwards compatibility.
authorId: session.author, authorId: session.author,
readOnly: session.readonly, readOnly: session.readonly,
@ -214,7 +217,7 @@ exports.handleDisconnect = async (socket:any) => {
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
exports.handleMessage = async (socket:any, message:typeof ChatMessage) => { export const handleMessage = async (socket:any, message: ClientVarMessage) => {
const env = process.env.NODE_ENV || 'development'; const env = process.env.NODE_ENV || 'development';
if (env === 'production') { if (env === 'production') {
@ -223,7 +226,7 @@ exports.handleMessage = async (socket:any, message:typeof ChatMessage) => {
} catch (err) { } catch (err) {
messageLogger.warn(`Rate limited IP ${socket.request.ip}. To reduce the amount of rate ` + messageLogger.warn(`Rate limited IP ${socket.request.ip}. To reduce the amount of rate ` +
'limiting that happens edit the rateLimit values in settings.json'); 'limiting that happens edit the rateLimit values in settings.json');
stats.meter('rateLimited').mark(); measuredCollection.meter('rateLimited').mark();
socket.emit('message', {disconnect: 'rateLimited'}); socket.emit('message', {disconnect: 'rateLimited'});
throw err; throw err;
} }
@ -246,14 +249,14 @@ exports.handleMessage = async (socket:any, message:typeof ChatMessage) => {
}; };
// Pad does not exist, so we need to sanitize the id // Pad does not exist, so we need to sanitize the id
if (!(await padManager.doesPadExist(thisSession.auth.padID))) { if (!(await doesPadExist(thisSession.auth.padID))) {
thisSession.auth.padID = await padManager.sanitizePadId(thisSession.auth.padID); thisSession.auth.padID = await sanitizePadId(thisSession.auth.padID);
} }
const padIds = await readOnlyManager.getIds(thisSession.auth.padID); const padIds = await getIds(thisSession.auth.padID);
thisSession.padId = padIds.padId; thisSession.padId = padIds.padId;
thisSession.readOnlyPadId = padIds.readOnlyPadId; thisSession.readOnlyPadId = padIds.readOnlyPadId;
thisSession.readonly = thisSession.readonly =
padIds.readonly || !webaccess.userCanModify(thisSession.auth.padID, socket.client.request); padIds.readonly || !userCanModify(thisSession.auth.padID, socket.client.request);
} }
// Outside of the checks done by this function, message.padId must not be accessed because it is // Outside of the checks done by this function, message.padId must not be accessed because it is
// too easy to introduce a security vulnerability that allows malicious users to read or modify // too easy to introduce a security vulnerability that allows malicious users to read or modify
@ -273,7 +276,7 @@ exports.handleMessage = async (socket:any, message:typeof ChatMessage) => {
const {session: {user} = {}} = socket.client.request as SocketClientRequest; const {session: {user} = {}} = socket.client.request as SocketClientRequest;
const {accessStatus, authorID} = const {accessStatus, authorID} =
await securityManager.checkAccess(auth.padID, auth.sessionID, auth.token, user); await checkAccess(auth.padID, auth.sessionID, auth.token, user);
if (accessStatus !== 'grant') { if (accessStatus !== 'grant') {
socket.emit('message', {accessStatus}); socket.emit('message', {accessStatus});
throw new Error('access denied'); throw new Error('access denied');
@ -303,16 +306,16 @@ exports.handleMessage = async (socket:any, message:typeof ChatMessage) => {
}, },
socket, socket,
get client() { get client() {
padutils.warnDeprecated( padUtils.warnDeprecated(
'the `client` context property for the handleMessageSecurity and handleMessage hooks ' + 'the `client` context property for the handleMessageSecurity and handleMessage hooks ' +
'is deprecated; use the `socket` property instead'); 'is deprecated; use the `socket` property instead');
return this.socket; return this.socket;
}, },
}; };
for (const res of await hooks.aCallAll('handleMessageSecurity', context)) { for (const res of await aCallAll('handleMessageSecurity', context)) {
switch (res) { switch (res) {
case true: case true:
padutils.warnDeprecated( padUtils.warnDeprecated(
'returning `true` from a `handleMessageSecurity` hook function is deprecated; ' + 'returning `true` from a `handleMessageSecurity` hook function is deprecated; ' +
'return "permitOnce" instead'); 'return "permitOnce" instead');
thisSession.readonly = false; thisSession.readonly = false;
@ -327,7 +330,7 @@ exports.handleMessage = async (socket:any, message:typeof ChatMessage) => {
} }
// Call handleMessage hook. If a plugin returns null, the message will be dropped. // Call handleMessage hook. If a plugin returns null, the message will be dropped.
if ((await hooks.aCallAll('handleMessage', context)).some((m: null|string) => m == null)) { if ((await aCallAll('handleMessage', context)).some((m: null|string) => m == null)) {
return; return;
} }
@ -345,7 +348,7 @@ exports.handleMessage = async (socket:any, message:typeof ChatMessage) => {
try { try {
switch (type) { switch (type) {
case 'USER_CHANGES': case 'USER_CHANGES':
stats.counter('pendingEdits').inc(); measuredCollection.counter('pendingEdits').inc();
await padChannels.enqueue(thisSession.padId, {socket, message}); await padChannels.enqueue(thisSession.padId, {socket, message});
break; break;
case 'USERINFO_UPDATE': await handleUserInfoUpdate(socket, message); break; case 'USERINFO_UPDATE': await handleUserInfoUpdate(socket, message); break;
@ -386,7 +389,7 @@ exports.handleMessage = async (socket:any, message:typeof ChatMessage) => {
*/ */
const handleSaveRevisionMessage = async (socket:any, message: string) => { const handleSaveRevisionMessage = async (socket:any, message: string) => {
const {padId, author: authorId} = sessioninfos[socket.id]; const {padId, author: authorId} = sessioninfos[socket.id];
const pad = await padManager.getPad(padId, null, authorId); const pad = await getPad(padId, null, authorId);
await pad.addSavedRevision(pad.head, authorId); await pad.addSavedRevision(pad.head, authorId);
}; };
@ -397,14 +400,14 @@ const handleSaveRevisionMessage = async (socket:any, message: string) => {
* @param msg {Object} the message we're sending * @param msg {Object} the message we're sending
* @param sessionID {string} the socketIO session to which we're sending this message * @param sessionID {string} the socketIO session to which we're sending this message
*/ */
exports.handleCustomObjectMessage = (msg: typeof ChatMessage, sessionID: string) => { export const handleCustomObjectMessage = (msg: ClientVarMessage, sessionID: string) => {
if (msg.data.type === 'CUSTOM') { if ("data" in msg && msg.type != 'CLIENT_VARS' && msg.data.type === 'CUSTOM') {
if (sessionID) { if (sessionID) {
// a sessionID is targeted: directly to this sessionID // a sessionID is targeted: directly to this sessionID
socketio.sockets.socket(sessionID).emit('message', msg); _socketio.sockets.socket(sessionID).emit('message', msg);
} else { } else {
// broadcast to all clients on this pad // broadcast to all clients on this pad
socketio.sockets.in(msg.data.payload.padId).emit('message', msg); _socketio.sockets.in(msg.data.payload.padId).emit('message', msg);
} }
} }
}; };
@ -415,7 +418,7 @@ exports.handleCustomObjectMessage = (msg: typeof ChatMessage, sessionID: string)
* @param padID {Pad} the pad to which we're sending this message * @param padID {Pad} the pad to which we're sending this message
* @param msgString {String} the message we're sending * @param msgString {String} the message we're sending
*/ */
exports.handleCustomMessage = (padID: string, msgString:string) => { export const handleCustomMessage = (padID: string, msgString:string) => {
const time = Date.now(); const time = Date.now();
const msg = { const msg = {
type: 'COLLABROOM', type: 'COLLABROOM',
@ -424,7 +427,7 @@ exports.handleCustomMessage = (padID: string, msgString:string) => {
time, time,
}, },
}; };
socketio.sockets.in(padID).emit('message', msg); _socketio.sockets.in(padID).emit('message', msg);
}; };
/** /**
@ -432,13 +435,13 @@ exports.handleCustomMessage = (padID: string, msgString:string) => {
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
const handleChatMessage = async (socket:any, message: typeof ChatMessage) => { const handleChatMessage = async (socket:any, message: ChatMessageMessage) => {
const chatMessage = ChatMessage.fromObject(message.data.message); const chatMessage = ChatMessage.fromObject(message.data.message);
const {padId, author: authorId} = sessioninfos[socket.id]; const {padId, author: authorId} = sessioninfos[socket.id];
// Don't trust the user-supplied values. // Don't trust the user-supplied values.
chatMessage.time = Date.now(); chatMessage.time = Date.now();
chatMessage.authorId = authorId; chatMessage.authorId = authorId;
await exports.sendChatMessageToPadClients(chatMessage, padId); await sendChatMessageToPadClients(chatMessage, padId);
}; };
/** /**
@ -452,16 +455,16 @@ const handleChatMessage = async (socket:any, message: typeof ChatMessage) => {
* @param {string} [padId] - The destination pad ID. Deprecated; pass a chat message * @param {string} [padId] - The destination pad ID. Deprecated; pass a chat message
* object as the first argument and the destination pad ID as the second argument instead. * object as the first argument and the destination pad ID as the second argument instead.
*/ */
exports.sendChatMessageToPadClients = async (mt: typeof ChatMessage|number, puId: string, text:string|null = null, padId:string|null = null) => { export const sendChatMessageToPadClients = async (mt: ChatMessage|number, puId: string, text:string|null = null, padId:string|null = null) => {
const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt); const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt);
padId = mt instanceof ChatMessage ? puId : padId; padId = mt instanceof ChatMessage ? puId : padId;
const pad = await padManager.getPad(padId, null, message.authorId); const pad = await getPad(padId!, null, message.authorId);
await hooks.aCallAll('chatNewMessage', {message, pad, padId}); await aCallAll('chatNewMessage', {message, pad, padId});
// pad.appendChatMessage() ignores the displayName property so we don't need to wait for // pad.appendChatMessage() ignores the displayName property so we don't need to wait for
// authorManager.getAuthorName() to resolve before saving the message to the database. // authorManager.getAuthorName() to resolve before saving the message to the database.
const promise = pad.appendChatMessage(message); const promise = pad.appendChatMessage(message);
message.displayName = await authorManager.getAuthorName(message.authorId); message.displayName = await getAuthorName(message.authorId!);
socketio.sockets.in(padId).emit('message', { _socketio.sockets.in(padId).emit('message', {
type: 'COLLABROOM', type: 'COLLABROOM',
data: {type: 'CHAT_MESSAGE', message}, data: {type: 'CHAT_MESSAGE', message},
}); });
@ -479,7 +482,7 @@ const handleGetChatMessages = async (socket:any, {data: {start, end}}:any) => {
const count = end - start; const count = end - start;
if (count < 0 || count > 100) throw new Error(`invalid number of messages: ${count}`); if (count < 0 || count > 100) throw new Error(`invalid number of messages: ${count}`);
const {padId, author: authorId} = sessioninfos[socket.id]; const {padId, author: authorId} = sessioninfos[socket.id];
const pad = await padManager.getPad(padId, null, authorId); const pad = await getPad(padId, null, authorId);
const chatMessages = await pad.getChatMessages(start, end); const chatMessages = await pad.getChatMessages(start, end);
const infoMsg = { const infoMsg = {
@ -499,7 +502,7 @@ const handleGetChatMessages = async (socket:any, {data: {start, end}}:any) => {
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
const handleSuggestUserName = (socket:any, message: typeof ChatMessage) => { const handleSuggestUserName = (socket:any, message: UserSuggestUserName) => {
const {newName, unnamedId} = message.data.payload; const {newName, unnamedId} = message.data.payload;
if (newName == null) throw new Error('missing newName'); if (newName == null) throw new Error('missing newName');
if (unnamedId == null) throw new Error('missing unnamedId'); if (unnamedId == null) throw new Error('missing unnamedId');
@ -531,8 +534,8 @@ const handleUserInfoUpdate = async (socket:any, {data: {userInfo: {name, colorId
// Tell the authorManager about the new attributes // Tell the authorManager about the new attributes
const p = Promise.all([ const p = Promise.all([
authorManager.setAuthorColorId(author, colorId), setAuthorColorId(author, colorId),
authorManager.setAuthorName(author, name), setAuthorName(author, name!),
]); ]);
const padId = session.padId; const padId = session.padId;
@ -567,9 +570,9 @@ const handleUserInfoUpdate = async (socket:any, {data: {userInfo: {name, colorId
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
const handleUserChanges = async (socket:any, message: typeof ChatMessage) => { const handleUserChanges = async (socket:any, message: UserChanges) => {
// This one's no longer pending, as we're gonna process it now // This one's no longer pending, as we're gonna process it now
stats.counter('pendingEdits').dec(); measuredCollection.counter('pendingEdits').dec();
// The client might disconnect between our callbacks. We should still // The client might disconnect between our callbacks. We should still
// finish processing the changeset, so keep a reference to the session. // finish processing the changeset, so keep a reference to the session.
@ -581,20 +584,20 @@ const handleUserChanges = async (socket:any, message: typeof ChatMessage) => {
if (!thisSession) throw new Error('client disconnected'); if (!thisSession) throw new Error('client disconnected');
// Measure time to process edit // Measure time to process edit
const stopWatch = stats.timer('edits').start(); const stopWatch = measuredCollection.timer('edits').start();
try { try {
const {data: {baseRev, apool, changeset}} = message; const {data: {baseRev, apool, changeset}} = message;
if (baseRev == null) throw new Error('missing baseRev'); if (baseRev == null) throw new Error('missing baseRev');
if (apool == null) throw new Error('missing apool'); if (apool == null) throw new Error('missing apool');
if (changeset == null) throw new Error('missing changeset'); if (changeset == null) throw new Error('missing changeset');
const wireApool = (new AttributePool()).fromJsonable(apool); const wireApool = (new AttributePool()).fromJsonable(apool);
const pad = await padManager.getPad(thisSession.padId, null, thisSession.author); const pad = await getPad(thisSession.padId, null, thisSession.author);
// Verify that the changeset has valid syntax and is in canonical form // Verify that the changeset has valid syntax and is in canonical form
Changeset.checkRep(changeset); checkRep(changeset);
// Validate all added 'author' attribs to be the same value as the current user // Validate all added 'author' attribs to be the same value as the current user
for (const op of Changeset.deserializeOps(Changeset.unpack(changeset).ops)) { for (const op of deserializeOps(unpack(changeset).ops)) {
// + can add text with attribs // + can add text with attribs
// = can change or add attribs // = can change or add attribs
// - can have attribs, but they are discarded and don't show up in the attribs - // - can have attribs, but they are discarded and don't show up in the attribs -
@ -613,7 +616,7 @@ const handleUserChanges = async (socket:any, message: typeof ChatMessage) => {
// ex. adoptChangesetAttribs // ex. adoptChangesetAttribs
// Afaik, it copies the new attributes from the changeset, to the global Attribute Pool // Afaik, it copies the new attributes from the changeset, to the global Attribute Pool
let rebasedChangeset = Changeset.moveOpsToNewPool(changeset, wireApool, pad.pool); let rebasedChangeset = moveOpsToNewPool(changeset, wireApool, pad.pool);
// ex. applyUserChanges // ex. applyUserChanges
let r = baseRev; let r = baseRev;
@ -626,24 +629,24 @@ const handleUserChanges = async (socket:any, message: typeof ChatMessage) => {
const {changeset: c, meta: {author: authorId}} = await pad.getRevision(r); const {changeset: c, meta: {author: authorId}} = await pad.getRevision(r);
if (changeset === c && thisSession.author === authorId) { if (changeset === c && thisSession.author === authorId) {
// Assume this is a retransmission of an already applied changeset. // Assume this is a retransmission of an already applied changeset.
rebasedChangeset = Changeset.identity(Changeset.unpack(changeset).oldLen); rebasedChangeset = identity(unpack(changeset).oldLen);
} }
// At this point, both "c" (from the pad) and "changeset" (from the // At this point, both "c" (from the pad) and "changeset" (from the
// client) are relative to revision r - 1. The follow function // client) are relative to revision r - 1. The follow function
// rebases "changeset" so that it is relative to revision r // rebases "changeset" so that it is relative to revision r
// and can be applied after "c". // and can be applied after "c".
rebasedChangeset = Changeset.follow(c, rebasedChangeset, false, pad.pool); rebasedChangeset = follow(c, rebasedChangeset, false, pad.pool);
} }
const prevText = pad.text(); const prevText = pad.text();
if (Changeset.oldLen(rebasedChangeset) !== prevText.length) { if (oldLen(rebasedChangeset) !== prevText.length) {
throw new Error( throw new Error(
`Can't apply changeset ${rebasedChangeset} with oldLen ` + `Can't apply changeset ${rebasedChangeset} with oldLen ` +
`${Changeset.oldLen(rebasedChangeset)} to document of length ${prevText.length}`); `${oldLen(rebasedChangeset)} to document of length ${prevText.length}`);
} }
const newRev = await pad.appendRevision(rebasedChangeset, thisSession.author); const newRev = await pad.appendRevision(rebasedChangeset, thisSession.author) as number;
// The head revision will either stay the same or increase by 1 depending on whether the // The head revision will either stay the same or increase by 1 depending on whether the
// changeset has a net effect. // changeset has a net effect.
assert([r, r + 1].includes(newRev)); assert([r, r + 1].includes(newRev));
@ -655,7 +658,7 @@ const handleUserChanges = async (socket:any, message: typeof ChatMessage) => {
// Make sure the pad always ends with an empty line. // Make sure the pad always ends with an empty line.
if (pad.text().lastIndexOf('\n') !== pad.text().length - 1) { if (pad.text().lastIndexOf('\n') !== pad.text().length - 1) {
const nlChangeset = Changeset.makeSplice(pad.text(), pad.text().length - 1, 0, '\n'); const nlChangeset = makeSplice(pad.text(), pad.text().length - 1, 0, '\n');
await pad.appendRevision(nlChangeset, thisSession.author); await pad.appendRevision(nlChangeset, thisSession.author);
} }
@ -665,10 +668,10 @@ const handleUserChanges = async (socket:any, message: typeof ChatMessage) => {
socket.emit('message', {type: 'COLLABROOM', data: {type: 'ACCEPT_COMMIT', newRev}}); socket.emit('message', {type: 'COLLABROOM', data: {type: 'ACCEPT_COMMIT', newRev}});
thisSession.rev = newRev; thisSession.rev = newRev;
if (newRev !== r) thisSession.time = await pad.getRevisionDate(newRev); if (newRev !== r) thisSession.time = await pad.getRevisionDate(newRev);
await exports.updatePadClients(pad); await updatePadClients(pad);
} catch (err:any) { } catch (err:any) {
socket.emit('message', {disconnect: 'badChangeset'}); socket.emit('message', {disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark(); measuredCollection.meter('failedChangesets').mark();
messageLogger.warn(`Failed to apply USER_CHANGES from author ${thisSession.author} ` + messageLogger.warn(`Failed to apply USER_CHANGES from author ${thisSession.author} ` +
`(socket ${socket.id}) on pad ${thisSession.padId}: ${err.stack || err}`); `(socket ${socket.id}) on pad ${thisSession.padId}: ${err.stack || err}`);
} finally { } finally {
@ -676,7 +679,7 @@ const handleUserChanges = async (socket:any, message: typeof ChatMessage) => {
} }
}; };
exports.updatePadClients = async (pad: PadType) => { export const updatePadClients = async (pad: Pad) => {
// skip this if no-one is on this pad // skip this if no-one is on this pad
const roomSockets = _getRoomSockets(pad.id); const roomSockets = _getRoomSockets(pad.id);
if (roomSockets.length === 0) return; if (roomSockets.length === 0) return;
@ -710,7 +713,7 @@ exports.updatePadClients = async (pad: PadType) => {
const revChangeset = revision.changeset; const revChangeset = revision.changeset;
const currentTime = revision.meta.timestamp; const currentTime = revision.meta.timestamp;
const forWire = Changeset.prepareForWire(revChangeset, pad.pool); const forWire = prepareForWire(revChangeset, pad.pool);
const msg = { const msg = {
type: 'COLLABROOM', type: 'COLLABROOM',
data: { data: {
@ -745,7 +748,7 @@ const _correctMarkersInPad = (atext: AText, apool: AttributePool) => {
// that aren't at the start of a line // that aren't at the start of a line
const badMarkers = []; const badMarkers = [];
let offset = 0; let offset = 0;
for (const op of Changeset.deserializeOps(atext.attribs)) { for (const op of deserializeOps(atext.attribs)) {
const attribs = AttributeMap.fromString(op.attribs, apool); const attribs = AttributeMap.fromString(op.attribs, apool);
const hasMarker = AttributeManager.lineAttributes.some((a: string) => attribs.has(a)); const hasMarker = AttributeManager.lineAttributes.some((a: string) => attribs.has(a));
if (hasMarker) { if (hasMarker) {
@ -767,7 +770,7 @@ const _correctMarkersInPad = (atext: AText, apool: AttributePool) => {
// create changeset that removes these bad markers // create changeset that removes these bad markers
offset = 0; offset = 0;
const builder = Changeset.builder(text.length); const builder = new Builder(text.length);
badMarkers.forEach((pos) => { badMarkers.forEach((pos) => {
builder.keepText(text.substring(offset, pos)); builder.keepText(text.substring(offset, pos));
@ -785,26 +788,29 @@ const _correctMarkersInPad = (atext: AText, apool: AttributePool) => {
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
const handleClientReady = async (socket:any, message: typeof ChatMessage) => { const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
const sessionInfo = sessioninfos[socket.id]; const sessionInfo = sessioninfos[socket.id];
if (sessionInfo == null) throw new Error('client disconnected'); if (sessionInfo == null) throw new Error('client disconnected');
assert(sessionInfo.author); assert(sessionInfo.author);
await hooks.aCallAll('clientReady', message); // Deprecated due to awkward context. await aCallAll('clientReady', message); // Deprecated due to awkward context.
let {colorId: authorColorId, name: authorName} = message.userInfo || {}; let {colorId: authorColorId, name: authorName} = message.userInfo || {};
// @ts-ignore
if (authorColorId && !/^#(?:[0-9A-F]{3}){1,2}$/i.test(authorColorId)) { if (authorColorId && !/^#(?:[0-9A-F]{3}){1,2}$/i.test(authorColorId)) {
messageLogger.warn(`Ignoring invalid colorId in CLIENT_READY message: ${authorColorId}`); messageLogger.warn(`Ignoring invalid colorId in CLIENT_READY message: ${authorColorId}`);
// @ts-ignore
authorColorId = null; authorColorId = null;
} }
await Promise.all([ await Promise.all([
authorName && authorManager.setAuthorName(sessionInfo.author, authorName), authorName && setAuthorName(sessionInfo.author, authorName),
authorColorId && authorManager.setAuthorColorId(sessionInfo.author, authorColorId), // @ts-ignore
authorColorId && setAuthorColorId(sessionInfo.author, authorColorId),
]); ]);
({colorId: authorColorId, name: authorName} = await authorManager.getAuthor(sessionInfo.author)); ({colorId: authorColorId, name: authorName} = await getAuthor(sessionInfo.author));
// load the pad-object from the database // load the pad-object from the database
const pad = await padManager.getPad(sessionInfo.padId, null, sessionInfo.author); const pad = await getPad(sessionInfo.padId, null, sessionInfo.author);
// these db requests all need the pad object (timestamp of latest revision, author data) // these db requests all need the pad object (timestamp of latest revision, author data)
const authors = pad.getAllAuthors(); const authors = pad.getAllAuthors();
@ -813,12 +819,9 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
const currentTime = await pad.getRevisionDate(pad.getHeadRevisionNumber()); const currentTime = await pad.getRevisionDate(pad.getHeadRevisionNumber());
// get all author data out of the database (in parallel) // get all author data out of the database (in parallel)
const historicalAuthorData:MapArrayType<{ const historicalAuthorData: HistoricalAuthorData = {};
name: string;
colorId: string;
}> = {};
await Promise.all(authors.map(async (authorId: string) => { await Promise.all(authors.map(async (authorId: string) => {
const author = await authorManager.getAuthor(authorId); const author = await getAuthor(authorId);
if (!author) { if (!author) {
messageLogger.error(`There is no author for authorId: ${authorId}. ` + messageLogger.error(`There is no author for authorId: ${authorId}. ` +
'This is possibly related to https://github.com/ether/etherpad-lite/issues/2802'); 'This is possibly related to https://github.com/ether/etherpad-lite/issues/2802');
@ -872,7 +875,7 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
const revisionsNeeded = []; const revisionsNeeded = [];
const changesets:MapArrayType<any> = {}; const changesets:MapArrayType<any> = {};
let startNum = message.client_rev + 1; let startNum = message.client_rev! + 1;
let endNum = pad.getHeadRevisionNumber() + 1; let endNum = pad.getHeadRevisionNumber() + 1;
const headNum = pad.getHeadRevisionNumber(); const headNum = pad.getHeadRevisionNumber();
@ -901,7 +904,7 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
// return pending changesets // return pending changesets
for (const r of revisionsNeeded) { for (const r of revisionsNeeded) {
const forWire = Changeset.prepareForWire(changesets[r].changeset, pad.pool); const forWire = prepareForWire(changesets[r].changeset, pad.pool);
const wireMsg = {type: 'COLLABROOM', const wireMsg = {type: 'COLLABROOM',
data: {type: 'CLIENT_RECONNECT', data: {type: 'CLIENT_RECONNECT',
headRev: pad.getHeadRevisionNumber(), headRev: pad.getHeadRevisionNumber(),
@ -923,11 +926,11 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
} else { } else {
// This is a normal first connect // This is a normal first connect
let atext; let atext;
let apool; let apool: AttributePoolWire;
// prepare all values for the wire, there's a chance that this throws, if the pad is corrupted // prepare all values for the wire, there's a chance that this throws, if the pad is corrupted
try { try {
atext = Changeset.cloneAText(pad.atext); atext = cloneAText(pad.atext);
const attribsForWire = Changeset.prepareForWire(atext.attribs, pad.pool); const attribsForWire = prepareForWire(atext.attribs, pad.pool);
apool = attribsForWire.pool.toJsonable(); apool = attribsForWire.pool.toJsonable();
atext.attribs = attribsForWire.translated; atext.attribs = attribsForWire.translated;
} catch (e:any) { } catch (e:any) {
@ -938,10 +941,10 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
// Warning: never ever send sessionInfo.padId to the client. If the client is read only you // Warning: never ever send sessionInfo.padId to the client. If the client is read only you
// would open a security hole 1 swedish mile wide... // would open a security hole 1 swedish mile wide...
const clientVars:MapArrayType<any> = { const clientVars:ClientVarPayload = {
skinName: settings.skinName, skinName: settings.skinName!,
skinVariants: settings.skinVariants, skinVariants: settings.skinVariants!,
randomVersionString: settings.randomVersionString, randomVersionString: settings.randomVersionString!,
accountPrivs: { accountPrivs: {
maxRevisions: 100, maxRevisions: 100,
}, },
@ -958,7 +961,7 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
rev: pad.getHeadRevisionNumber(), rev: pad.getHeadRevisionNumber(),
time: currentTime, time: currentTime,
}, },
colorPalette: authorManager.getColorPalette(), colorPalette: getColorPalette(),
clientIp: '127.0.0.1', clientIp: '127.0.0.1',
userColor: authorColorId, userColor: authorColorId,
padId: sessionInfo.auth.padID, padId: sessionInfo.auth.padID,
@ -979,8 +982,8 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
sofficeAvailable: settings.sofficeAvailable(), sofficeAvailable: settings.sofficeAvailable(),
exportAvailable: settings.exportAvailable(), exportAvailable: settings.exportAvailable(),
plugins: { plugins: {
plugins: plugins.plugins, plugins: pluginDefs.getPlugins(),
parts: plugins.parts, parts: pluginDefs.getParts(),
}, },
indentationOnNewLine: settings.indentationOnNewLine, indentationOnNewLine: settings.indentationOnNewLine,
scrollWhenFocusLineIsOutOfViewport: { scrollWhenFocusLineIsOutOfViewport: {
@ -997,7 +1000,7 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp, settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp,
}, },
initialChangesets: [], // FIXME: REMOVE THIS SHIT, initialChangesets: [], // FIXME: REMOVE THIS SHIT,
mode: process.env.NODE_ENV mode: process.env.NODE_ENV!
}; };
// Add a username to the clientVars if one avaiable // Add a username to the clientVars if one avaiable
@ -1006,7 +1009,7 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
} }
// call the clientVars-hook so plugins can modify them before they get sent to the client // call the clientVars-hook so plugins can modify them before they get sent to the client
const messages = await hooks.aCallAll('clientVars', {clientVars, pad, socket}); const messages = await aCallAll('clientVars', {clientVars, pad, socket});
// combine our old object with the new attributes from the hook // combine our old object with the new attributes from the hook
for (const msg of messages) { for (const msg of messages) {
@ -1052,7 +1055,7 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
if (authorId == null) return; if (authorId == null) return;
// reuse previously created cache of author's data // reuse previously created cache of author's data
const authorInfo = historicalAuthorData[authorId] || await authorManager.getAuthor(authorId); const authorInfo = historicalAuthorData[authorId] || await getAuthor(authorId);
if (authorInfo == null) { if (authorInfo == null) {
messageLogger.error( messageLogger.error(
`Author ${authorId} connected via socket.io session ${roomSocket.id} is missing from ` + `Author ${authorId} connected via socket.io session ${roomSocket.id} is missing from ` +
@ -1077,7 +1080,7 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
socket.emit('message', msg); socket.emit('message', msg);
})); }));
await hooks.aCallAll('userJoin', { await aCallAll('userJoin', {
authorId: sessionInfo.author, authorId: sessionInfo.author,
displayName: authorName, displayName: authorName,
padId: sessionInfo.padId, padId: sessionInfo.padId,
@ -1090,7 +1093,7 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
/** /**
* Handles a request for a rough changeset, the timeslider client needs it * Handles a request for a rough changeset, the timeslider client needs it
*/ */
const handleChangesetRequest = async (socket:any, {data: {granularity, start, requestID}}: ChangesetRequest) => { const handleChangesetRequest = async (socket:any, {data: {granularity, start, requestID}}: ChangesetRequestMessage) => {
if (granularity == null) throw new Error('missing granularity'); if (granularity == null) throw new Error('missing granularity');
if (!Number.isInteger(granularity)) throw new Error('granularity is not an integer'); if (!Number.isInteger(granularity)) throw new Error('granularity is not an integer');
if (start == null) throw new Error('missing start'); if (start == null) throw new Error('missing start');
@ -1098,7 +1101,7 @@ const handleChangesetRequest = async (socket:any, {data: {granularity, start, re
if (requestID == null) throw new Error('mising requestID'); if (requestID == null) throw new Error('mising requestID');
const end = start + (100 * granularity); const end = start + (100 * granularity);
const {padId, author: authorId} = sessioninfos[socket.id]; const {padId, author: authorId} = sessioninfos[socket.id];
const pad = await padManager.getPad(padId, null, authorId); const pad = await getPad(padId, null, authorId);
const headRev = pad.getHeadRevisionNumber(); const headRev = pad.getHeadRevisionNumber();
if (start > headRev) if (start > headRev)
start = headRev; start = headRev;
@ -1111,7 +1114,7 @@ const handleChangesetRequest = async (socket:any, {data: {granularity, start, re
* Tries to rebuild the getChangestInfo function of the original Etherpad * Tries to rebuild the getChangestInfo function of the original Etherpad
* https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L144 * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L144
*/ */
const getChangesetInfo = async (pad: PadType, startNum: number, endNum:number, granularity: number) => { const getChangesetInfo = async (pad: Pad, startNum: number, endNum:number, granularity: number) => {
const headRevision = pad.getHeadRevisionNumber(); const headRevision = pad.getHeadRevisionNumber();
// calculate the last full endnum // calculate the last full endnum
@ -1163,13 +1166,13 @@ const getChangesetInfo = async (pad: PadType, startNum: number, endNum:number, g
if (compositeEnd > endNum || compositeEnd > headRevision + 1) break; if (compositeEnd > endNum || compositeEnd > headRevision + 1) break;
const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`]; const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`];
const backwards = Changeset.inverse(forwards, lines.textlines, lines.alines, pad.apool()); const backwards = inverse(forwards, lines.textlines, lines.alines, pad.apool());
Changeset.mutateAttributionLines(forwards, lines.alines, pad.apool()); new AttributionLinesMutator(forwards, lines.alines, pad.apool());
Changeset.mutateTextLines(forwards, lines.textlines); mutateTextLines(forwards, lines.textlines);
const forwards2 = Changeset.moveOpsToNewPool(forwards, pad.apool(), apool); const forwards2 = moveOpsToNewPool(forwards, pad.apool(), apool);
const backwards2 = Changeset.moveOpsToNewPool(backwards, pad.apool(), apool); const backwards2 = moveOpsToNewPool(backwards, pad.apool(), apool);
const t1 = (compositeStart === 0) ? revisionDate[0] : revisionDate[compositeStart - 1]; const t1 = (compositeStart === 0) ? revisionDate[0] : revisionDate[compositeStart - 1];
const t2 = revisionDate[compositeEnd - 1]; const t2 = revisionDate[compositeEnd - 1];
@ -1188,19 +1191,19 @@ const getChangesetInfo = async (pad: PadType, startNum: number, endNum:number, g
* Tries to rebuild the getPadLines function of the original Etherpad * Tries to rebuild the getPadLines function of the original Etherpad
* https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L263 * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L263
*/ */
const getPadLines = async (pad: PadType, revNum: number) => { const getPadLines = async (pad: Pad, revNum: number) => {
// get the atext // get the atext
let atext; let atext;
if (revNum >= 0) { if (revNum >= 0) {
atext = await pad.getInternalRevisionAText(revNum); atext = await pad.getInternalRevisionAText(revNum);
} else { } else {
atext = Changeset.makeAText('\n'); atext = makeAText('\n');
} }
return { return {
textlines: Changeset.splitTextLines(atext.text), textlines: splitTextLines(atext.text),
alines: Changeset.splitAttributionLines(atext.attribs, atext.text), alines: splitAttributionLines(atext.attribs, atext.text),
}; };
}; };
@ -1208,7 +1211,7 @@ const getPadLines = async (pad: PadType, revNum: number) => {
* Tries to rebuild the composePadChangeset function of the original Etherpad * Tries to rebuild the composePadChangeset function of the original Etherpad
* https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L241 * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L241
*/ */
const composePadChangesets = async (pad: PadType, startNum: number, endNum: number) => { const composePadChangesets = async (pad: Pad, startNum: number, endNum: number) => {
// fetch all changesets we need // fetch all changesets we need
const headNum = pad.getHeadRevisionNumber(); const headNum = pad.getHeadRevisionNumber();
endNum = Math.min(endNum, headNum + 1); endNum = Math.min(endNum, headNum + 1);
@ -1235,7 +1238,7 @@ const composePadChangesets = async (pad: PadType, startNum: number, endNum: numb
for (r = startNum + 1; r < endNum; r++) { for (r = startNum + 1; r < endNum; r++) {
const cs = changesets[r]; const cs = changesets[r];
changeset = Changeset.compose(changeset, cs, pool); changeset = compose(changeset, cs, pool);
} }
return changeset; return changeset;
} catch (e) { } catch (e) {
@ -1247,7 +1250,7 @@ const composePadChangesets = async (pad: PadType, startNum: number, endNum: numb
}; };
const _getRoomSockets = (padID: string) => { const _getRoomSockets = (padID: string) => {
const ns = socketio.sockets; // Default namespace. const ns = _socketio.sockets; // Default namespace.
// We could call adapter.clients(), but that method is unnecessarily asynchronous. Replicate what // We could call adapter.clients(), but that method is unnecessarily asynchronous. Replicate what
// it does here, but synchronously to avoid a race condition. This code will have to change when // it does here, but synchronously to avoid a race condition. This code will have to change when
// we update to socket.io v3. // we update to socket.io v3.
@ -1263,21 +1266,21 @@ const _getRoomSockets = (padID: string) => {
/** /**
* Get the number of users in a pad * Get the number of users in a pad
*/ */
exports.padUsersCount = (padID:string) => ({ export const padUsersCount = (padID:string) => ({
padUsersCount: _getRoomSockets(padID).length, padUsersCount: _getRoomSockets(padID).length,
}); });
/** /**
* Get the list of users in a pad * Get the list of users in a pad
*/ */
exports.padUsers = async (padID: string) => { export const padUsers = async (padID: string) => {
const padUsers:PadAuthor[] = []; const padUsers:PadAuthor[] = [];
// iterate over all clients (in parallel) // iterate over all clients (in parallel)
await Promise.all(_getRoomSockets(padID).map(async (roomSocket) => { await Promise.all(_getRoomSockets(padID).map(async (roomSocket) => {
const s = sessioninfos[roomSocket.id]; const s = sessioninfos[roomSocket.id];
if (s) { if (s) {
const author = await authorManager.getAuthor(s.author); const author = await getAuthor(s.author);
// Fixes: https://github.com/ether/etherpad-lite/issues/4120 // Fixes: https://github.com/ether/etherpad-lite/issues/4120
// On restart author might not be populated? // On restart author might not be populated?
if (author) { if (author) {
@ -1289,5 +1292,3 @@ exports.padUsers = async (padID: string) => {
return {padUsers}; return {padUsers};
}; };
exports.sessioninfos = sessioninfos;

View file

@ -1,49 +1,50 @@
'use strict'; 'use strict';
import {Socket} from "node:net";
import type {MapArrayType} from "../types/MapType"; import type {MapArrayType} from "../types/MapType";
import _ from 'underscore'; import _ from 'underscore';
// @ts-ignore
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import events from 'events'; import events from 'events';
import express from 'express'; import express from 'express';
// @ts-ignore // @ts-ignore
import expressSession from '@etherpad/express-session'; import expressSession from '@etherpad/express-session';
import fs from 'fs'; import fs from 'fs';
const hooks = require('../../static/js/pluginfw/hooks'); import {aCallAll} from '../../static/js/pluginfw/hooks';
import log4js from 'log4js'; import log4js from 'log4js';
const SessionStore = require('../db/SessionStore'); import SessionStore from '../db/SessionStore';
const settings = require('../utils/Settings'); import settings from '../utils/Settings';
const stats = require('../stats') import {measuredCollection} from '../stats';
import util from 'util'; import util from 'util';
const webaccess = require('./express/webaccess'); import {checkAccess} from './express/webaccess';
import SecretRotator from '../security/SecretRotator'; import SecretRotator from '../security/SecretRotator';
import {Server, Socket} from "socket.io";
import {DefaultEventsMap} from "socket.io/dist/typed-events";
let secretRotator: SecretRotator|null = null; let secretRotator: SecretRotator|null = null;
const logger = log4js.getLogger('http'); const logger = log4js.getLogger('http');
let serverName:string; let serverName:string;
let sessionStore: { shutdown: () => void; } | null; let sessionStore: { shutdown: () => void; } | null;
const sockets:Set<Socket> = new Set(); const sockets:Set< Socket<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>> = new Set();
const socketsEvents = new events.EventEmitter(); const socketsEvents = new events.EventEmitter();
const startTime = stats.settableGauge('httpStartTime'); const startTime = measuredCollection.settableGauge('httpStartTime');
exports.server = null;
const closeServer = async () => { const closeServer = async () => {
if (exports.server != null) { if (server != null) {
logger.info('Closing HTTP server...'); logger.info('Closing HTTP server...');
// Call exports.server.close() to reject new connections but don't await just yet because the // Call server.close() to reject new connections but don't await just yet because the
// Promise won't resolve until all preexisting connections are closed. // Promise won't resolve until all preexisting connections are closed.
const p = util.promisify(exports.server.close.bind(exports.server))(); const p = util.promisify(server.close.bind(server))();
await hooks.aCallAll('expressCloseServer'); await aCallAll('expressCloseServer');
// Give existing connections some time to close on their own before forcibly terminating. The // Give existing connections some time to close on their own before forcibly terminating. The
// time should be long enough to avoid interrupting most preexisting transmissions but short // time should be long enough to avoid interrupting most preexisting transmissions but short
// enough to avoid a noticeable outage. // enough to avoid a noticeable outage.
const timeout = setTimeout(async () => { const timeout = setTimeout(async () => {
logger.info(`Forcibly terminating remaining ${sockets.size} HTTP connections...`); logger.info(`Forcibly terminating remaining ${sockets.size} HTTP connections...`);
for (const socket of sockets) socket.destroy(new Error('HTTP server is closing')); for (const socket of sockets) { // @ts-ignore
socket.destroy(new Error('HTTP server is closing'));
}
}, 5000); }, 5000);
let lastLogged = 0; let lastLogged = 0;
while (sockets.size > 0 && !settings.enableAdminUITests) { while (sockets.size > 0 && !settings.enableAdminUITests) {
@ -55,7 +56,7 @@ const closeServer = async () => {
} }
await p; await p;
clearTimeout(timeout); clearTimeout(timeout);
exports.server = null; server = null;
startTime.setValue(0); startTime.setValue(0);
logger.info('HTTP server closed'); logger.info('HTTP server closed');
} }
@ -65,14 +66,14 @@ const closeServer = async () => {
secretRotator = null; secretRotator = null;
}; };
exports.createServer = async () => { export const createServer = async () => {
console.log('Report bugs at https://github.com/ether/etherpad-lite/issues'); console.log('Report bugs at https://github.com/ether/etherpad-lite/issues');
serverName = `Etherpad ${settings.getGitCommit()} (https://etherpad.org)`; serverName = `Etherpad ${settings.getGitCommit()} (https://etherpad.org)`;
console.log(`Your Etherpad version is ${settings.getEpVersion()} (${settings.getGitCommit()})`); console.log(`Your Etherpad version is ${settings.getEpVersion()} (${settings.getGitCommit()})`);
await exports.restartServer(); await restartServer();
if (settings.ip === '') { if (settings.ip === '') {
// using Unix socket for connectivity // using Unix socket for connectivity
@ -97,7 +98,10 @@ exports.createServer = async () => {
} }
}; };
exports.restartServer = async () => { export let server: Server|null = null;
export let sessionMiddleware:any
export const restartServer = async () => {
await closeServer(); await closeServer();
const app = express(); // New syntax for express v3 const app = express(); // New syntax for express v3
@ -121,10 +125,11 @@ exports.restartServer = async () => {
} }
const https = require('https'); const https = require('https');
exports.server = https.createServer(options, app);
server = https.createServer(options, app);
} else { } else {
const http = require('http'); const http = require('http');
exports.server = http.createServer(app); server = http.createServer(app);
} }
app.use((req, res, next) => { app.use((req, res, next) => {
@ -167,7 +172,7 @@ exports.restartServer = async () => {
// Measure response time // Measure response time
app.use((req, res, next) => { app.use((req, res, next) => {
const stopWatch = stats.timer('httpRequests').start(); const stopWatch = measuredCollection.timer('httpRequests').start();
const sendFn = res.send.bind(res); const sendFn = res.send.bind(res);
res.send = (...args) => { stopWatch.end(); return sendFn(...args); }; res.send = (...args) => { stopWatch.end(); return sendFn(...args); };
next(); next();
@ -177,7 +182,8 @@ exports.restartServer = async () => {
// starts listening to requests as reported in issue #158. Not installing the log4js connect // starts listening to requests as reported in issue #158. Not installing the log4js connect
// logger when the log level has a higher severity than INFO since it would not log at that level // logger when the log level has a higher severity than INFO since it would not log at that level
// anyway. // anyway.
if (!(settings.loglevel === 'WARN' && settings.loglevel === 'ERROR')) { // @ts-ignore
if (!(loglevel === 'WARN' && loglevel === 'ERROR')) {
app.use(log4js.connectLogger(logger, { app.use(log4js.connectLogger(logger, {
level: log4js.levels.DEBUG.levelStr, level: log4js.levels.DEBUG.levelStr,
format: ':status, :method :url', format: ':status, :method :url',
@ -185,7 +191,7 @@ exports.restartServer = async () => {
} }
const {keyRotationInterval, sessionLifetime} = settings.cookie; const {keyRotationInterval, sessionLifetime} = settings.cookie;
let secret = settings.sessionKey; let secret: string|string[] = settings.sessionKey!;
if (keyRotationInterval && sessionLifetime) { if (keyRotationInterval && sessionLifetime) {
secretRotator = new SecretRotator( secretRotator = new SecretRotator(
'expressSessionSecrets', keyRotationInterval, sessionLifetime, settings.sessionKey); 'expressSessionSecrets', keyRotationInterval, sessionLifetime, settings.sessionKey);
@ -197,7 +203,7 @@ exports.restartServer = async () => {
app.use(cookieParser(secret, {})); app.use(cookieParser(secret, {}));
sessionStore = new SessionStore(settings.cookie.sessionRefreshInterval); sessionStore = new SessionStore(settings.cookie.sessionRefreshInterval);
exports.sessionMiddleware = expressSession({ sessionMiddleware = expressSession({
propagateTouch: true, propagateTouch: true,
rolling: true, rolling: true,
secret, secret,
@ -234,16 +240,16 @@ exports.restartServer = async () => {
// Give plugins an opportunity to install handlers/middleware before the express-session // Give plugins an opportunity to install handlers/middleware before the express-session
// middleware. This allows plugins to avoid creating an express-session record in the database // middleware. This allows plugins to avoid creating an express-session record in the database
// when it is not needed (e.g., public static content). // when it is not needed (e.g., public static content).
await hooks.aCallAll('expressPreSession', {app}); await aCallAll('expressPreSession', {app});
app.use(exports.sessionMiddleware); app.use(sessionMiddleware);
app.use(webaccess.checkAccess); app.use(checkAccess);
await Promise.all([ await Promise.all([
hooks.aCallAll('expressConfigure', {app}), aCallAll('expressConfigure', {app}),
hooks.aCallAll('expressCreateServer', {app, server: exports.server}), aCallAll('expressCreateServer', {app, server: server}),
]); ]);
exports.server.on('connection', (socket:Socket) => { server!.on('connection', (socket) => {
sockets.add(socket); sockets.add(socket);
socketsEvents.emit('updated'); socketsEvents.emit('updated');
socket.on('close', () => { socket.on('close', () => {
@ -251,11 +257,12 @@ exports.restartServer = async () => {
socketsEvents.emit('updated'); socketsEvents.emit('updated');
}); });
}); });
await util.promisify(exports.server.listen).bind(exports.server)(settings.port, settings.ip); // @ts-ignore
await util.promisify(server!.listen).bind(server)(port, ip);
startTime.setValue(Date.now()); 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) => { export const shutdown = async (hookName:string, context: any) => {
await closeServer(); await closeServer();
}; };

View file

@ -3,10 +3,10 @@
import {Dirent} from "node:fs"; import {Dirent} from "node:fs";
import {PluginDef} from "../../types/PartType"; import {PluginDef} from "../../types/PartType";
const path = require('path'); import path from 'path';
const fsp = require('fs').promises; import {promises as fsp} from 'fs';
const plugins = require('../../../static/js/pluginfw/plugin_defs'); import {pluginDefs} from '../../../static/js/pluginfw/plugin_defs';
const sanitizePathname = require('../../utils/sanitizePathname'); import sanitizePathname from '../../utils/sanitizePathname';
const settings = require('../../utils/Settings'); const settings = require('../../utils/Settings');
// Returns all *.js files under specDir (recursively) as relative paths to specDir, using '/' // Returns all *.js files under specDir (recursively) as relative paths to specDir, using '/'
@ -32,11 +32,11 @@ const findSpecs = async (specDir: string) => {
return specs; return specs;
}; };
exports.expressPreSession = async (hookName:string, {app}:any) => { export const expressPreSession = async (hookName:string, {app}:any) => {
app.get('/tests/frontend/frontendTestSpecs.json', (req:any, res:any, next:Function) => { app.get('/tests/frontend/frontendTestSpecs.json', (req:any, res:any, next:Function) => {
(async () => { (async () => {
const modules:string[] = []; const modules:string[] = [];
await Promise.all(Object.entries(plugins.plugins).map(async ([plugin, def]) => { await Promise.all(Object.entries(pluginDefs.getPlugins()).map(async ([plugin, def]) => {
let {package: {path: pluginPath}} = def as PluginDef; let {package: {path: pluginPath}} = def as PluginDef;
if (!pluginPath.endsWith(path.sep)) pluginPath += path.sep; if (!pluginPath.endsWith(path.sep)) pluginPath += path.sep;
const specDir = `${plugin === 'ep_etherpad-lite' ? '' : 'static/'}tests/frontend/specs`; const specDir = `${plugin === 'ep_etherpad-lite' ? '' : 'static/'}tests/frontend/specs`;

View file

@ -7,21 +7,19 @@ import {WebAccessTypes} from "../../types/WebAccessTypes";
import {SettingsUser} from "../../types/SettingsUser"; import {SettingsUser} from "../../types/SettingsUser";
const httpLogger = log4js.getLogger('http'); const httpLogger = log4js.getLogger('http');
const settings = require('../../utils/Settings'); const settings = require('../../utils/Settings');
const hooks = require('../../../static/js/pluginfw/hooks'); import {deprecationNotices, aCallFirst as HookAcall} from '../../../static/js/pluginfw/hooks';
const readOnlyManager = require('../../db/ReadOnlyManager'); const readOnlyManager = require('../../db/ReadOnlyManager');
hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead'; deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead';
// Promisified wrapper around hooks.aCallFirst. // Promisified wrapper around hooks.aCallFirst.
const aCallFirst = (hookName: string, context:any, pred = null) => new Promise((resolve, reject) => { const aCallFirst = (hookName: string, context:any, pred = null) => new Promise((resolve, reject) => {
hooks.aCallFirst(hookName, context, (err:any, r: unknown) => err != null ? reject(err) : resolve(r), pred); HookAcall(hookName, context, (err:any, r: unknown) => err != null ? reject(err) : resolve(r), pred)
}); });
const aCallFirst0 = const aCallFirst0 = async (hookName: string, context:any, pred = null): Promise<any> => (await aCallFirst(hookName, context, pred)) as any[0];
// @ts-ignore
async (hookName: string, context:any, pred = null) => (await aCallFirst(hookName, context, pred))[0];
exports.normalizeAuthzLevel = (level: string|boolean) => { export const normalizeAuthzLevel = (level: string|boolean) => {
if (!level) return false; if (!level) return false;
switch (level) { switch (level) {
case true: case true:
@ -36,20 +34,20 @@ exports.normalizeAuthzLevel = (level: string|boolean) => {
return false; return false;
}; };
exports.userCanModify = (padId: string, req: SocketClientRequest) => { export const userCanModify = (padId: string, req: SocketClientRequest) => {
if (readOnlyManager.isReadOnlyId(padId)) return false; if (readOnlyManager.isReadOnlyId(padId)) return false;
if (!settings.requireAuthentication) return true; if (!settings.requireAuthentication) return true;
const {session: {user} = {}} = req; const {session: {user} = {}} = req;
if (!user || user.readOnly) return false; if (!user || user.readOnly) return false;
assert(user.padAuthorizations); // This is populated even if !settings.requireAuthorization. assert(user.padAuthorizations); // This is populated even if !settings.requireAuthorization.
const level = exports.normalizeAuthzLevel(user.padAuthorizations[padId]); const level = normalizeAuthzLevel(user.padAuthorizations[padId]);
return level && level !== 'readOnly'; return level && level !== 'readOnly';
}; };
// Exported so that tests can set this to 0 to avoid unnecessary test slowness. // Exported so that tests can set this to 0 to avoid unnecessary test slowness.
exports.authnFailureDelayMs = 1000; export const authnFailureDelayMs = 1000;
const checkAccess = async (req:any, res:any, next: Function) => { const _checkAccess = async (req:any, res:any, next: Function) => {
const requireAdmin = req.path.toLowerCase().startsWith('/admin-auth'); const requireAdmin = req.path.toLowerCase().startsWith('/admin-auth');
// /////////////////////////////////////////////////////////////////////////////////////////////// // ///////////////////////////////////////////////////////////////////////////////////////////////
@ -93,7 +91,7 @@ const checkAccess = async (req:any, res:any, next: Function) => {
// authentication is checked and once after (if settings.requireAuthorization is true). // authentication is checked and once after (if settings.requireAuthorization is true).
const authorize = async () => { const authorize = async () => {
const grant = async (level: string|false) => { const grant = async (level: string|false) => {
level = exports.normalizeAuthzLevel(level); level = normalizeAuthzLevel(level);
if (!level) return false; if (!level) return false;
const user = req.session.user; const user = req.session.user;
if (user == null) return true; // This will happen if authentication is not required. if (user == null) return true; // This will happen if authentication is not required.
@ -173,7 +171,7 @@ const checkAccess = async (req:any, res:any, next: Function) => {
res.header('WWW-Authenticate', 'Basic realm="Protected Area"'); res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
} }
// Delay the error response for 1s to slow down brute force attacks. // Delay the error response for 1s to slow down brute force attacks.
await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs)); await new Promise((resolve) => setTimeout(resolve, authnFailureDelayMs));
res.status(401).send('Authentication Required'); res.status(401).send('Authentication Required');
return; return;
} }
@ -213,6 +211,6 @@ const checkAccess = async (req:any, res:any, next: Function) => {
* Express middleware to authenticate the user and check authorization. Must be installed after the * Express middleware to authenticate the user and check authorization. Must be installed after the
* express-session middleware. * express-session middleware.
*/ */
exports.checkAccess = (req:any, res:any, next:Function) => { export const checkAccess = (req:any, res:any, next:Function) => {
checkAccess(req, res, next).catch((err) => next(err || new Error(err))); _checkAccess(req, res, next).catch((err) => next(err || new Error(err)));
}; };

View file

@ -1,10 +1,10 @@
'use strict'; 'use strict';
const securityManager = require('./db/SecurityManager'); import {checkAccess} from './db/SecurityManager';
// checks for padAccess // checks for padAccess
module.exports = async (req: { params?: any; cookies?: any; session?: any; }, res: { status: (arg0: number) => { (): any; new(): any; send: { (arg0: string): void; new(): any; }; }; }) => { export default async (req: { params?: any; cookies?: any; session?: any; }, res: { status: (arg0: number) => { (): any; new(): any; send: { (arg0: string): void; new(): any; }; }; }) => {
const {session: {user} = {}} = req; const {session: {user} = {}} = req;
const accessObj = await securityManager.checkAccess( const accessObj = await checkAccess(
req.params.pad, req.cookies.sessionID, req.cookies.token, user); req.params.pad, req.cookies.sessionID, req.cookies.token, user);
if (accessObj.accessStatus === 'grant') { if (accessObj.accessStatus === 'grant') {

View file

@ -28,8 +28,7 @@ import log4js from 'log4js';
import pkg from '../package.json'; import pkg from '../package.json';
import {checkForMigration} from "../static/js/pluginfw/installer"; import {checkForMigration} from "../static/js/pluginfw/installer";
import axios from "axios"; import axios from "axios";
import settings from "./utils/Settings";
const settings = require('./utils/Settings');
let wtfnode: any; let wtfnode: any;
if (settings.dumpOnUncleanExit) { if (settings.dumpOnUncleanExit) {
@ -64,18 +63,18 @@ if (process.env['https_proxy']) {
* early check for version compatibility before calling * early check for version compatibility before calling
* any modules that require newer versions of NodeJS * any modules that require newer versions of NodeJS
*/ */
const NodeVersion = require('./utils/NodeVersion'); import {checkDeprecationStatus, enforceMinNodeVersion} from './utils/NodeVersion';
NodeVersion.enforceMinNodeVersion(pkg.engines.node.replace(">=", "")); enforceMinNodeVersion(pkg.engines.node.replace(">=", ""));
NodeVersion.checkDeprecationStatus(pkg.engines.node.replace(">=", ""), '2.1.0'); checkDeprecationStatus(pkg.engines.node.replace(">=", ""), '2.1.0');
const UpdateCheck = require('./utils/UpdateCheck'); import {check} from './utils/UpdateCheck';
const db = require('./db/DB'); import {init} from './db/DB';
const express = require('./hooks/express'); import {server} from './hooks/express';
const hooks = require('../static/js/pluginfw/hooks'); import {aCallAll} from '../static/js/pluginfw/hooks';
const pluginDefs = require('../static/js/pluginfw/plugin_defs'); import {pluginDefs} from '../static/js/pluginfw/plugin_defs';
const plugins = require('../static/js/pluginfw/plugins'); import {formatHooks, formatParts, update} from '../static/js/pluginfw/plugins';
const {Gate} = require('./utils/promises'); import {Gate} from './utils/promises';
const stats = require('./stats') import {measuredCollection} from './stats';
const logger = log4js.getLogger('server'); const logger = log4js.getLogger('server');
@ -100,17 +99,17 @@ const removeSignalListener = (signal: NodeJS.Signals, listener: NodeJS.SignalsLi
}; };
let startDoneGate: { resolve: () => void; } let startDoneGate: Gate<void>
exports.start = async () => { export const start = async (): Promise<any> => {
switch (state) { switch (state) {
case State.INITIAL: case State.INITIAL:
break; break;
case State.STARTING: case State.STARTING:
await startDoneGate; await startDoneGate;
// Retry. Don't fall through because it might have transitioned to STATE_TRANSITION_FAILED. // Retry. Don't fall through because it might have transitioned to STATE_TRANSITION_FAILED.
return await exports.start(); return await start();
case State.RUNNING: case State.RUNNING:
return express.server; return server;
case State.STOPPING: case State.STOPPING:
case State.STOPPED: case State.STOPPED:
case State.EXITING: case State.EXITING:
@ -121,22 +120,20 @@ exports.start = async () => {
throw new Error(`unknown State: ${state.toString()}`); throw new Error(`unknown State: ${state.toString()}`);
} }
logger.info('Starting Etherpad...'); logger.info('Starting Etherpad...');
startDoneGate = new Gate(); startDoneGate = new Gate<void>();
state = State.STARTING; state = State.STARTING;
try { try {
// Check if Etherpad version is up-to-date // Check if Etherpad version is up-to-date
UpdateCheck.check(); check();
// @ts-ignore measuredCollection.gauge('memoryUsage', () => process.memoryUsage().rss);
stats.gauge('memoryUsage', () => process.memoryUsage().rss); measuredCollection.gauge('memoryUsageHeap', () => process.memoryUsage().heapUsed);
// @ts-ignore
stats.gauge('memoryUsageHeap', () => process.memoryUsage().heapUsed);
process.on('uncaughtException', (err: ErrorCaused) => { process.on('uncaughtException', (err: ErrorCaused) => {
logger.debug(`uncaught exception: ${err.stack || err}`); logger.debug(`uncaught exception: ${err.stack || err}`);
// eslint-disable-next-line promise/no-promise-in-callback // eslint-disable-next-line promise/no-promise-in-callback
exports.exit(err) exit(err)
.catch((err: ErrorCaused) => { .catch((err: ErrorCaused) => {
logger.error('Error in process exit', err); logger.error('Error in process exit', err);
// eslint-disable-next-line n/no-process-exit // eslint-disable-next-line n/no-process-exit
@ -153,12 +150,12 @@ exports.start = async () => {
for (const signal of ['SIGINT', 'SIGTERM'] as NodeJS.Signals[]) { for (const signal of ['SIGINT', 'SIGTERM'] as NodeJS.Signals[]) {
// Forcibly remove other signal listeners to prevent them from terminating node before we are // Forcibly remove other signal listeners to prevent them from terminating node before we are
// done cleaning up. See https://github.com/andywer/threads.js/pull/329 for an example of a // done cleaning up. See https://github.com/andywer/threads.js/pull/329 for an example of a
// problematic listener. This means that exports.exit is solely responsible for performing all // problematic listener. This means that exit is solely responsible for performing all
// necessary cleanup tasks. // necessary cleanup tasks.
for (const listener of process.listeners(signal)) { for (const listener of process.listeners(signal)) {
removeSignalListener(signal, listener); removeSignalListener(signal, listener);
} }
process.on(signal, exports.exit); process.on(signal, exit);
// Prevent signal listeners from being added in the future. // Prevent signal listeners from being added in the future.
process.on('newListener', (event, listener) => { process.on('newListener', (event, listener) => {
if (event !== signal) return; if (event !== signal) return;
@ -166,40 +163,40 @@ exports.start = async () => {
}); });
} }
await db.init(); await init();
await checkForMigration(); await checkForMigration();
await plugins.update(); await update();
const installedPlugins = (Object.values(pluginDefs.plugins) as PluginType[]) const installedPlugins = (Object.values(pluginDefs.getPlugins()) as PluginType[])
.filter((plugin) => plugin.package.name !== 'ep_etherpad-lite') .filter((plugin) => plugin.package.name !== 'ep_etherpad-lite')
.map((plugin) => `${plugin.package.name}@${plugin.package.version}`) .map((plugin) => `${plugin.package.name}@${plugin.package.version}`)
.join(', '); .join(', ');
logger.info(`Installed plugins: ${installedPlugins}`); logger.info(`Installed plugins: ${installedPlugins}`);
logger.debug(`Installed parts:\n${plugins.formatParts()}`); logger.debug(`Installed parts:\n${formatParts()}`);
logger.debug(`Installed server-side hooks:\n${plugins.formatHooks('hooks', false)}`); logger.debug(`Installed server-side hooks:\n${formatHooks('hooks', false)}`);
await hooks.aCallAll('loadSettings', {settings}); await aCallAll('loadSettings', {settings});
await hooks.aCallAll('createServer'); await aCallAll('createServer');
} catch (err) { } catch (err) {
logger.error('Error occurred while starting Etherpad'); logger.error('Error occurred while starting Etherpad');
state = State.STATE_TRANSITION_FAILED; state = State.STATE_TRANSITION_FAILED;
startDoneGate.resolve(); startDoneGate.resolve!();
return await exports.exit(err); return await exit(err as any);
} }
logger.info('Etherpad is running'); logger.info('Etherpad is running');
state = State.RUNNING; state = State.RUNNING;
startDoneGate.resolve(); startDoneGate.resolve!();
// Return the HTTP server to make it easier to write tests. // Return the HTTP server to make it easier to write tests.
return express.server; return server;
}; };
const stopDoneGate = new Gate(); const stopDoneGate = new Gate();
exports.stop = async () => { export const stop = async (): Promise<void> => {
switch (state) { switch (state) {
case State.STARTING: case State.STARTING:
await exports.start(); await start();
// Don't fall through to State.RUNNING in case another caller is also waiting for startup. // Don't fall through to State.RUNNING in case another caller is also waiting for startup.
return await exports.stop(); return await stop();
case State.RUNNING: case State.RUNNING:
break; break;
case State.STOPPING: case State.STOPPING:
@ -219,7 +216,7 @@ exports.stop = async () => {
try { try {
let timeout: NodeJS.Timeout = null as unknown as NodeJS.Timeout; let timeout: NodeJS.Timeout = null as unknown as NodeJS.Timeout;
await Promise.race([ await Promise.race([
hooks.aCallAll('shutdown'), aCallAll('shutdown'),
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
timeout = setTimeout(() => reject(new Error('Timed out waiting for shutdown tasks')), 3000); timeout = setTimeout(() => reject(new Error('Timed out waiting for shutdown tasks')), 3000);
}), }),
@ -228,24 +225,25 @@ exports.stop = async () => {
} catch (err) { } catch (err) {
logger.error('Error occurred while stopping Etherpad'); logger.error('Error occurred while stopping Etherpad');
state = State.STATE_TRANSITION_FAILED; state = State.STATE_TRANSITION_FAILED;
stopDoneGate.resolve(); stopDoneGate.resolve!();
return await exports.exit(err); // @ts-ignore
return await exit(err);
} }
logger.info('Etherpad stopped'); logger.info('Etherpad stopped');
state = State.STOPPED; state = State.STOPPED;
stopDoneGate.resolve(); stopDoneGate.resolve!();
}; };
let exitGate: any; let exitGate: any;
let exitCalled = false; let exitCalled = false;
exports.exit = async (err: ErrorCaused|string|null = null) => { export const exit = async (err: ErrorCaused|string|null = null): Promise<void> => {
/* eslint-disable no-process-exit */ /* eslint-disable no-process-exit */
if (err === 'SIGTERM') { if (err === 'SIGTERM') {
// Termination from SIGTERM is not treated as an abnormal termination. // Termination from SIGTERM is not treated as an abnormal termination.
logger.info('Received SIGTERM signal'); logger.info('Received SIGTERM signal');
err = null; err = null;
} else if (typeof err == "object" && err != null) { } else if (typeof err == "object" && err != null) {
logger.error(`Metrics at time of fatal error:\n${JSON.stringify(stats.toJSON(), null, 2)}`); logger.error(`Metrics at time of fatal error:\n${JSON.stringify(measuredCollection.toJSON(), null, 2)}`);
logger.error(err.stack || err.toString()); logger.error(err.stack || err.toString());
process.exitCode = 1; process.exitCode = 1;
if (exitCalled) { if (exitCalled) {
@ -259,11 +257,11 @@ exports.exit = async (err: ErrorCaused|string|null = null) => {
case State.STARTING: case State.STARTING:
case State.RUNNING: case State.RUNNING:
case State.STOPPING: case State.STOPPING:
await exports.stop(); await stop();
// Don't fall through to State.STOPPED in case another caller is also waiting for stop(). // Don't fall through to State.STOPPED in case another caller is also waiting for stop().
// Don't pass err to exports.exit() because this err has already been processed. (If err is // Don't pass err to exports.exit() because this err has already been processed. (If err is
// passed again to exit() then exit() will think that a second error occurred while exiting.) // passed again to exit() then exit() will think that a second error occurred while exiting.)
return await exports.exit(); return await exit();
case State.INITIAL: case State.INITIAL:
case State.STOPPED: case State.STOPPED:
case State.STATE_TRANSITION_FAILED: case State.STATE_TRANSITION_FAILED:
@ -303,7 +301,7 @@ exports.exit = async (err: ErrorCaused|string|null = null) => {
/* eslint-enable no-process-exit */ /* eslint-enable no-process-exit */
}; };
if (require.main === module) exports.start(); if (require.main === module) start();
// @ts-ignore // @ts-ignore
if (typeof(PhusionPassenger) !== 'undefined') exports.start(); if (typeof(PhusionPassenger) !== 'undefined') start();

View file

@ -2,9 +2,8 @@
const measured = require('measured-core'); const measured = require('measured-core');
module.exports = measured.createCollection(); export const measuredCollection = measured.createCollection();
// @ts-ignore export const shutdown = async (hookName: string, context:any) => {
module.exports.shutdown = async (hookName, context) => { measuredCollection.end();
module.exports.end();
}; };

View file

@ -1,6 +1,6 @@
export type RunCMDOptions = { export type RunCMDOptions = {
cwd?: string, cwd?: string,
stdio?: string[], stdio?: string[]|null[],
env?: NodeJS.ProcessEnv env?: NodeJS.ProcessEnv
} }

View file

@ -1,3 +1,5 @@
import {UserSettingsObject} from "./UserSettingsObject";
export type SocketClientRequest = { export type SocketClientRequest = {
session: { session: {
user: { user: {
@ -5,6 +7,7 @@ export type SocketClientRequest = {
readOnly: boolean; readOnly: boolean;
padAuthorizations: { padAuthorizations: {
[key: string]: string; [key: string]: string;
user: UserSettingsObject
} }
} }
} }

View file

@ -22,15 +22,17 @@
import {ChildProcess} from "node:child_process"; import {ChildProcess} from "node:child_process";
import {AsyncQueueTask} from "../types/AsyncQueueTask"; import {AsyncQueueTask} from "../types/AsyncQueueTask";
const spawn = require('child_process').spawn; import {spawn} from 'child_process'
const async = require('async'); import async from 'async';
const settings = require('./Settings'); const settings = require('./Settings');
const os = require('os'); import os from 'os';
export let convertFile: (srcFile: string, destFile: string, type: string)=> Promise<void>
// on windows we have to spawn a process for each convertion, // on windows we have to spawn a process for each convertion,
// cause the plugin abicommand doesn't exist on this platform // cause the plugin abicommand doesn't exist on this platform
if (os.type().indexOf('Windows') > -1) { if (os.type().indexOf('Windows') > -1) {
exports.convertFile = async (srcFile: string, destFile: string, type: string) => { convertFile = async (srcFile: string, destFile: string, type: string) => {
const abiword = spawn(settings.abiword, [`--to=${destFile}`, srcFile]); const abiword = spawn(settings.abiword, [`--to=${destFile}`, srcFile]);
let stdoutBuffer = ''; let stdoutBuffer = '';
abiword.stdout.on('data', (data: string) => { stdoutBuffer += data.toString(); }); abiword.stdout.on('data', (data: string) => { stdoutBuffer += data.toString(); });
@ -87,7 +89,7 @@ if (os.type().indexOf('Windows') > -1) {
}; };
}, 1); }, 1);
exports.convertFile = async (srcFile: string, destFile: string, type: string) => { convertFile = async (srcFile: string, destFile: string, type: string) => {
await queue.pushAsync({srcFile, destFile, type}); await queue.pushAsync({srcFile, destFile, type});
}; };
} }

View file

@ -18,9 +18,9 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const log4js = require('log4js'); import log4js from 'log4js';
const path = require('path'); import path from 'path';
const _ = require('underscore'); import _ from 'underscore';
const absPathLogger = log4js.getLogger('AbsolutePaths'); const absPathLogger = log4js.getLogger('AbsolutePaths');
@ -74,7 +74,7 @@ const popIfEndsWith = (stringArray: string[], lastDesiredElements: string[]): st
* @return {string} The identified absolute base path. If such path cannot be * @return {string} The identified absolute base path. If such path cannot be
* identified, prints a log and exits the application. * identified, prints a log and exits the application.
*/ */
exports.findEtherpadRoot = () => { export const findEtherpadRoot = () => {
if (etherpadRoot != null) { if (etherpadRoot != null) {
return etherpadRoot; return etherpadRoot;
} }
@ -130,12 +130,12 @@ exports.findEtherpadRoot = () => {
* it is returned unchanged. Otherwise it is interpreted * it is returned unchanged. Otherwise it is interpreted
* relative to exports.root. * relative to exports.root.
*/ */
exports.makeAbsolute = (somePath: string) => { export const makeAbsolute = (somePath: string) => {
if (path.isAbsolute(somePath)) { if (path.isAbsolute(somePath)) {
return somePath; return somePath;
} }
const rewrittenPath = path.join(exports.findEtherpadRoot(), somePath); const rewrittenPath = path.join(findEtherpadRoot(), somePath);
absPathLogger.debug(`Relative path "${somePath}" can be rewritten to "${rewrittenPath}"`); absPathLogger.debug(`Relative path "${somePath}" can be rewritten to "${rewrittenPath}"`);
return rewrittenPath; return rewrittenPath;
@ -149,7 +149,7 @@ exports.makeAbsolute = (somePath: string) => {
* a subdirectory of the base one * a subdirectory of the base one
* @return {boolean} * @return {boolean}
*/ */
exports.isSubdir = (parent: string, arbitraryDir: string): boolean => { export const isSubdir = (parent: string, arbitraryDir: string): boolean => {
// modified from: https://stackoverflow.com/questions/37521893/determine-if-a-path-is-subdirectory-of-another-in-node-js#45242825 // modified from: https://stackoverflow.com/questions/37521893/determine-if-a-path-is-subdirectory-of-another-in-node-js#45242825
const relative = path.relative(parent, arbitraryDir); const relative = path.relative(parent, arbitraryDir);
return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative); return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative);

View file

@ -21,7 +21,9 @@
*/ */
// An object containing the parsed command-line options // An object containing the parsed command-line options
exports.argv = {}; import {MapArrayType} from "../types/MapType";
export const argvP: MapArrayType<any> = {};
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
let arg, prevArg; let arg, prevArg;
@ -32,22 +34,22 @@ for (let i = 0; i < argv.length; i++) {
// Override location of settings.json file // Override location of settings.json file
if (prevArg === '--settings' || prevArg === '-s') { if (prevArg === '--settings' || prevArg === '-s') {
exports.argv.settings = arg; argvP.settings = arg;
} }
// Override location of credentials.json file // Override location of credentials.json file
if (prevArg === '--credentials') { if (prevArg === '--credentials') {
exports.argv.credentials = arg; argvP.credentials = arg;
} }
// Override location of settings.json file // Override location of settings.json file
if (prevArg === '--sessionkey') { if (prevArg === '--sessionkey') {
exports.argv.sessionkey = arg; argvP.sessionkey = arg;
} }
// Override location of APIKEY.txt file // Override location of APIKEY.txt file
if (prevArg === '--apikey') { if (prevArg === '--apikey') {
exports.argv.apikey = arg; argvP.apikey = arg;
} }
prevArg = arg; prevArg = arg;

View file

@ -15,35 +15,36 @@
* limitations under the License. * limitations under the License.
*/ */
const Stream = require('./Stream'); import Stream from './Stream';
const assert = require('assert').strict; import {strict as assert} from 'assert'
const authorManager = require('../db/AuthorManager'); import {getAuthor} from '../db/AuthorManager';
const hooks = require('../../static/js/pluginfw/hooks'); import {aCallAll} from '../../static/js/pluginfw/hooks';
const padManager = require('../db/PadManager'); import {getPad} from '../db/PadManager';
import {findKeys, get} from "../db/DB";
exports.getPadRaw = async (padId:string, readOnlyId:string) => { export const getPadRaw = async (padId:string, readOnlyId:string) => {
const dstPfx = `pad:${readOnlyId || padId}`; const dstPfx = `pad:${readOnlyId || padId}`;
const [pad, customPrefixes] = await Promise.all([ const [pad, customPrefixes] = await Promise.all([
padManager.getPad(padId), getPad(padId),
hooks.aCallAll('exportEtherpadAdditionalContent'), aCallAll('exportEtherpadAdditionalContent'),
]); ]);
const pluginRecords = await Promise.all(customPrefixes.map(async (customPrefix:string) => { const pluginRecords = await Promise.all(customPrefixes.map(async (customPrefix:string) => {
const srcPfx = `${customPrefix}:${padId}`; const srcPfx = `${customPrefix}:${padId}`;
const dstPfx = `${customPrefix}:${readOnlyId || padId}`; const dstPfx = `${customPrefix}:${readOnlyId || padId}`;
assert(!srcPfx.includes('*')); assert(!srcPfx.includes('*'));
const srcKeys = await pad.db.findKeys(`${srcPfx}:*`, null); const srcKeys = await findKeys(`${srcPfx}:*`, null);
return (function* () { return (function* () {
yield [dstPfx, pad.db.get(srcPfx)]; yield [dstPfx, get(srcPfx)];
for (const k of srcKeys) { for (const k of srcKeys) {
assert(k.startsWith(`${srcPfx}:`)); assert(k.startsWith(`${srcPfx}:`));
yield [`${dstPfx}${k.slice(srcPfx.length)}`, pad.db.get(k)]; yield [`${dstPfx}${k.slice(srcPfx.length)}`, get(k)];
} }
})(); })();
})); }));
const records = (function* () { const records = (function* () {
for (const authorId of pad.getAllAuthors()) { for (const authorId of pad.getAllAuthors()) {
yield [`globalAuthor:${authorId}`, (async () => { yield [`globalAuthor:${authorId}`, (async () => {
const authorEntry = await authorManager.getAuthor(authorId); const authorEntry = await getAuthor(authorId);
if (!authorEntry) return undefined; // Becomes unset when converted to JSON. if (!authorEntry) return undefined; // Becomes unset when converted to JSON.
if (authorEntry.padIDs) authorEntry.padIDs = readOnlyId || padId; if (authorEntry.padIDs) authorEntry.padIDs = readOnlyId || padId;
return authorEntry; return authorEntry;
@ -55,7 +56,7 @@ exports.getPadRaw = async (padId:string, readOnlyId:string) => {
})(); })();
const data = {[dstPfx]: pad}; const data = {[dstPfx]: pad};
for (const [dstKey, p] of new Stream(records).batch(100).buffer(99)) data[dstKey] = await p; for (const [dstKey, p] of new Stream(records).batch(100).buffer(99)) data[dstKey] = await p;
await hooks.aCallAll('exportEtherpad', { await aCallAll('exportEtherpad', {
pad, pad,
data, data,
dstPadId: readOnlyId || padId, dstPadId: readOnlyId || padId,

View file

@ -21,17 +21,17 @@
import AttributeMap from '../../static/js/AttributeMap'; import AttributeMap from '../../static/js/AttributeMap';
import AttributePool from "../../static/js/AttributePool"; import AttributePool from "../../static/js/AttributePool";
const Changeset = require('../../static/js/Changeset'); import {deserializeOps, splitAttributionLines, subattribution} from '../../static/js/Changeset';
const { checkValidRev } = require('./checkValidRev'); import {checkValidRev} from './checkValidRev';
/* /*
* This method seems unused in core and no plugins depend on it * This method seems unused in core and no plugins depend on it
*/ */
exports.getPadPlainText = (pad: { getInternalRevisionAText: (arg0: any) => any; atext: any; pool: any; }, revNum: undefined) => { export const getPadPlainText = (pad: { getInternalRevisionAText: (arg0: any) => any; atext: any; pool: any; }, revNum: undefined) => {
const _analyzeLine = exports._analyzeLine; const _analyzeLine = exports._analyzeLine;
const atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(checkValidRev(revNum)) : pad.atext); const atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(checkValidRev(revNum)) : pad.atext);
const textLines = atext.text.slice(0, -1).split('\n'); const textLines = atext.text.slice(0, -1).split('\n');
const attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); const attribLines = splitAttributionLines(atext.attribs, atext.text);
const apool = pad.pool; const apool = pad.pool;
const pieces = []; const pieces = [];
@ -52,14 +52,14 @@ type LineModel = {
[id:string]:string|number|LineModel [id:string]:string|number|LineModel
} }
exports._analyzeLine = (text:string, aline: LineModel, apool: AttributePool) => { export const _analyzeLine = (text:string, aline: string, apool: AttributePool) => {
const line: LineModel = {}; const line: LineModel = {};
// identify list // identify list
let lineMarker = 0; let lineMarker = 0;
line.listLevel = 0; line.listLevel = 0;
if (aline) { if (aline) {
const [op] = Changeset.deserializeOps(aline); const [op] = deserializeOps(aline);
if (op != null) { if (op != null) {
const attribs = AttributeMap.fromString(op.attribs, apool); const attribs = AttributeMap.fromString(op.attribs, apool);
let listType = attribs.get('list'); let listType = attribs.get('list');
@ -79,7 +79,7 @@ exports._analyzeLine = (text:string, aline: LineModel, apool: AttributePool) =>
} }
if (lineMarker) { if (lineMarker) {
line.text = text.substring(1); line.text = text.substring(1);
line.aline = Changeset.subattribution(aline, 1); line.aline = subattribution(aline, 1);
} else { } else {
line.text = text; line.text = text;
line.aline = aline; line.aline = aline;
@ -88,5 +88,5 @@ exports._analyzeLine = (text:string, aline: LineModel, apool: AttributePool) =>
}; };
exports._encodeWhitespace = export const _encodeWhitespace =
(s:string) => s.replace(/[^\x21-\x7E\s\t\n\r]/gu, (c) => `&#${c.codePointAt(0)};`); (s:string) => s.replace(/[^\x21-\x7E\s\t\n\r]/gu, (c) => `&#${c.codePointAt(0)};`);

View file

@ -1,6 +1,7 @@
'use strict'; 'use strict';
import {AText, PadType} from "../types/PadType"; import {AText, PadType} from "../types/PadType";
import {MapArrayType} from "../types/MapType"; import {MapArrayType} from "../types/MapType";
import Pad from "../db/Pad";
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
@ -18,18 +19,19 @@ import {MapArrayType} from "../types/MapType";
* limitations under the License. * limitations under the License.
*/ */
const Changeset = require('../../static/js/Changeset'); import {deserializeOps, splitAttributionLines, subattribution} from '../../static/js/Changeset';
const attributes = require('../../static/js/attributes'); import {decodeAttribString} from '../../static/js/attributes';
const padManager = require('../db/PadManager'); import {getPad} from '../db/PadManager';
const _ = require('underscore'); import _ from 'underscore';
const Security = require('security'); const Security = require('security');
const hooks = require('../../static/js/pluginfw/hooks'); import {aCallAll} from '../../static/js/pluginfw/hooks';
const eejs = require('../eejs'); import {requireP} from '../eejs';
const _analyzeLine = require('./ExportHelper')._analyzeLine; import {_analyzeLine, _encodeWhitespace} from './ExportHelper'
const _encodeWhitespace = require('./ExportHelper')._encodeWhitespace; import {StringIterator} from "../../static/js/StringIterator";
import {StringAssembler} from "../../static/js/StringAssembler";
const padutils = require('../../static/js/pad_utils').padutils; const padutils = require('../../static/js/pad_utils').padutils;
const getPadHTML = async (pad: PadType, revNum: string) => { export const getPadHTML = async (pad: Pad, revNum: number) => {
let atext = pad.atext; let atext = pad.atext;
// fetch revision atext // fetch revision atext
@ -41,17 +43,17 @@ const getPadHTML = async (pad: PadType, revNum: string) => {
return await getHTMLFromAtext(pad, atext); return await getHTMLFromAtext(pad, atext);
}; };
const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string[]) => { export const getHTMLFromAtext = async (pad:Pad, atext: AText, authorColors?: string[]) => {
const apool = pad.apool(); const apool = pad.apool();
const textLines = atext.text.slice(0, -1).split('\n'); const textLines = atext.text.slice(0, -1).split('\n');
const attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); const attribLines = splitAttributionLines(atext.attribs, atext.text);
const tags = ['h1', 'h2', 'strong', 'em', 'u', 's']; const tags = ['h1', 'h2', 'strong', 'em', 'u', 's'];
const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough']; const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough'];
await Promise.all([ await Promise.all([
// prepare tags stored as ['tag', true] to be exported // prepare tags stored as ['tag', true] to be exported
hooks.aCallAll('exportHtmlAdditionalTags', pad).then((newProps: string[]) => { aCallAll('exportHtmlAdditionalTags', pad).then((newProps: string[]) => {
newProps.forEach((prop) => { newProps.forEach((prop) => {
tags.push(prop); tags.push(prop);
props.push(prop); props.push(prop);
@ -59,7 +61,7 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
}), }),
// prepare tags stored as ['tag', 'value'] to be exported. This will generate HTML with tags // prepare tags stored as ['tag', 'value'] to be exported. This will generate HTML with tags
// like <span data-tag="value"> // like <span data-tag="value">
hooks.aCallAll('exportHtmlAdditionalTagsWithData', pad).then((newProps: string[]) => { aCallAll('exportHtmlAdditionalTagsWithData', pad).then((newProps: string[]) => {
newProps.forEach((prop) => { newProps.forEach((prop) => {
tags.push(`span data-${prop[0]}="${prop[1]}"`); tags.push(`span data-${prop[0]}="${prop[1]}"`);
props.push(prop); props.push(prop);
@ -80,6 +82,7 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
css += '<style>\n'; css += '<style>\n';
for (const a of Object.keys(apool.numToAttrib)) { for (const a of Object.keys(apool.numToAttrib)) {
// @ts-ignore
const attr = apool.numToAttrib[a]; const attr = apool.numToAttrib[a];
// skip non author attributes // skip non author attributes
@ -115,20 +118,21 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
// see hook exportHtmlAdditionalTagsWithData // see hook exportHtmlAdditionalTagsWithData
attrib = propName; attrib = propName;
} }
// @ts-ignore
const propTrueNum = apool.putAttrib(attrib, true); const propTrueNum = apool.putAttrib(attrib, true);
if (propTrueNum >= 0) { if (propTrueNum >= 0) {
anumMap[propTrueNum] = i; anumMap[propTrueNum] = i;
} }
}); });
const getLineHTML = (text: string, attribs: string[]) => { const getLineHTML = (text: string, attribs: string) => {
// Use order of tags (b/i/u) as order of nesting, for simplicity // Use order of tags (b/i/u) as order of nesting, for simplicity
// and decent nesting. For example, // and decent nesting. For example,
// <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i> // <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i>
// becomes // becomes
// <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i> // <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i>
const taker = Changeset.stringIterator(text); const taker = new StringIterator(text);
const assem = Changeset.stringAssembler(); const assem = new StringAssembler();
const openTags:string[] = []; const openTags:string[] = [];
const getSpanClassFor = (i: string) => { const getSpanClassFor = (i: string) => {
@ -204,7 +208,7 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
return; return;
} }
const ops = Changeset.deserializeOps(Changeset.subattribution(attribs, idx, idx + numChars)); const ops = deserializeOps(subattribution(attribs, idx, idx + numChars));
idx += numChars; idx += numChars;
// this iterates over every op string and decides which tags to open or to close // this iterates over every op string and decides which tags to open or to close
@ -213,7 +217,7 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
const usedAttribs:string[] = []; const usedAttribs:string[] = [];
// mark all attribs as used // mark all attribs as used
for (const a of attributes.decodeAttribString(o.attribs)) { for (const a of decodeAttribString(o.attribs)) {
if (a in anumMap) { if (a in anumMap) {
usedAttribs.push(String(anumMap[a])); // i = 0 => bold, etc. usedAttribs.push(String(anumMap[a])); // i = 0 => bold, etc.
} }
@ -307,7 +311,7 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
for (let i = 0; i < textLines.length; i++) { for (let i = 0; i < textLines.length; i++) {
let context; let context;
const line = _analyzeLine(textLines[i], attribLines[i], apool); const line = _analyzeLine(textLines[i], attribLines[i], apool);
const lineContent = getLineHTML(line.text, line.aline); const lineContent = getLineHTML(line.text as string, line.aline as string);
// If we are inside a list // If we are inside a list
if (line.listLevel) { if (line.listLevel) {
context = { context = {
@ -326,7 +330,7 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
if (i < textLines.length) { if (i < textLines.length) {
nextLine = _analyzeLine(textLines[i + 1], attribLines[i + 1], apool); nextLine = _analyzeLine(textLines[i + 1], attribLines[i + 1], apool);
} }
await hooks.aCallAll('getLineHTMLForExport', context); await aCallAll('getLineHTMLForExport', context);
// To create list parent elements // To create list parent elements
if ((!prevLine || prevLine.listLevel !== line.listLevel) || if ((!prevLine || prevLine.listLevel !== line.listLevel) ||
(line.listTypeName !== prevLine.listTypeName)) { (line.listTypeName !== prevLine.listTypeName)) {
@ -335,14 +339,14 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
if (!exists) { if (!exists) {
let prevLevel = 0; let prevLevel = 0;
if (prevLine && prevLine.listLevel) { if (prevLine && prevLine.listLevel) {
prevLevel = prevLine.listLevel; prevLevel = prevLine.listLevel as number;
} }
if (prevLine && line.listTypeName !== prevLine.listTypeName) { if (prevLine && line.listTypeName !== prevLine.listTypeName) {
prevLevel = 0; prevLevel = 0;
} }
for (let diff = prevLevel; diff < line.listLevel; diff++) { for (let diff = prevLevel; diff < (line.listLevel as number); diff++) {
openLists.push({level: diff, type: line.listTypeName}); openLists.push({level: diff, type: line.listTypeName as string});
const prevPiece = pieces[pieces.length - 1]; const prevPiece = pieces[pieces.length - 1];
if (prevPiece.indexOf('<ul') === 0 || if (prevPiece.indexOf('<ul') === 0 ||
@ -372,7 +376,7 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
// pieces.push("</li>"); // pieces.push("</li>");
*/ */
if ((nextLine.listTypeName === 'number') && (nextLine.text === '')) { if ((nextLine!.listTypeName === 'number') && (nextLine!.text === '')) {
// is the listTypeName check needed here? null text might be completely fine! // is the listTypeName check needed here? null text might be completely fine!
// TODO Check against Uls // TODO Check against Uls
// don't do anything because the next item is a nested ol openener so // don't do anything because the next item is a nested ol openener so
@ -431,13 +435,13 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
(line.listTypeName !== nextLine.listTypeName)) { (line.listTypeName !== nextLine.listTypeName)) {
let nextLevel = 0; let nextLevel = 0;
if (nextLine && nextLine.listLevel) { if (nextLine && nextLine.listLevel) {
nextLevel = nextLine.listLevel; nextLevel = nextLine.listLevel as number;
} }
if (nextLine && line.listTypeName !== nextLine.listTypeName) { if (nextLine && line.listTypeName !== nextLine.listTypeName) {
nextLevel = 0; nextLevel = 0;
} }
for (let diff = nextLevel; diff < line.listLevel; diff++) { for (let diff = nextLevel; diff < (line.listLevel as number); diff++) {
openLists = openLists.filter((el) => el.level !== diff && el.type !== line.listTypeName); openLists = openLists.filter((el) => el.level !== diff && el.type !== line.listTypeName);
if (pieces[pieces.length - 1].indexOf('</ul') === 0 || if (pieces[pieces.length - 1].indexOf('</ul') === 0 ||
@ -463,7 +467,7 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
padId: pad.id, padId: pad.id,
}; };
await hooks.aCallAll('getLineHTMLForExport', context); await aCallAll('getLineHTMLForExport', context);
pieces.push(context.lineContent, '<br>'); pieces.push(context.lineContent, '<br>');
} }
} }
@ -471,23 +475,24 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
return pieces.join(''); return pieces.join('');
}; };
exports.getPadHTMLDocument = async (padId: string, revNum: string, readOnlyId: number) => { export const getPadHTMLDocument = async (padId: string, revNum: number, readOnlyId: number) => {
const pad = await padManager.getPad(padId); const pad = await getPad(padId);
// Include some Styles into the Head for Export // Include some Styles into the Head for Export
let stylesForExportCSS = ''; let stylesForExportCSS = '';
const stylesForExport: string[] = await hooks.aCallAll('stylesForExport', padId); const stylesForExport: string[] = await aCallAll('stylesForExport', padId);
stylesForExport.forEach((css) => { stylesForExport.forEach((css) => {
stylesForExportCSS += css; stylesForExportCSS += css;
}); });
let html = await getPadHTML(pad, revNum); let html = await getPadHTML(pad, revNum);
for (const hookHtml of await hooks.aCallAll('exportHTMLAdditionalContent', {padId})) { for (const hookHtml of await aCallAll('exportHTMLAdditionalContent', {padId})) {
html += hookHtml; html += hookHtml;
} }
return eejs.require('ep_etherpad-lite/templates/export_html.html', { return requireP('ep_etherpad-lite/templates/export_html.html', {
// @ts-ignore
body: html, body: html,
padId: Security.escapeHTML(readOnlyId || padId), padId: Security.escapeHTML(readOnlyId || padId),
extraCSS: stylesForExportCSS, extraCSS: stylesForExportCSS,
@ -543,5 +548,3 @@ const _processSpaces = (s: string) => {
return parts.join(''); return parts.join('');
}; };
exports.getPadHTML = getPadHTML;
exports.getHTMLFromAtext = getHTMLFromAtext;

View file

@ -19,16 +19,19 @@
* limitations under the License. * limitations under the License.
*/ */
import {AText, PadType} from "../types/PadType"; import {AText} from "../types/PadType";
import {MapType} from "../types/MapType"; import {MapType} from "../types/MapType";
const Changeset = require('../../static/js/Changeset'); import {deserializeOps, splitAttributionLines, subattribution} from '../../static/js/Changeset';
const attributes = require('../../static/js/attributes'); import {decodeAttribString} from '../../static/js/attributes';
const padManager = require('../db/PadManager'); import {getPad} from '../db/PadManager';
const _analyzeLine = require('./ExportHelper')._analyzeLine; import {StringIterator} from "../../static/js/StringIterator";
import {StringAssembler} from "../../static/js/StringAssembler";
import Pad from "../db/Pad";
import {_analyzeLine} from "./ExportHelper";
// This is slightly different than the HTML method as it passes the output to getTXTFromAText // This is slightly different than the HTML method as it passes the output to getTXTFromAText
const getPadTXT = async (pad: PadType, revNum: string) => { const getPadTXT = async (pad: Pad, revNum: number) => {
let atext = pad.atext; let atext = pad.atext;
if (revNum !== undefined) { if (revNum !== undefined) {
@ -40,18 +43,19 @@ const getPadTXT = async (pad: PadType, revNum: string) => {
return getTXTFromAtext(pad, atext); return getTXTFromAtext(pad, atext);
}; };
// This is different than the functionality provided in ExportHtml as it provides formatting // This is different from the functionality provided in ExportHtml as it provides formatting
// functionality that is designed specifically for TXT exports // functionality that is designed specifically for TXT exports
const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => { export const getTXTFromAtext = (pad: Pad, atext: AText, authorColors?:string) => {
const apool = pad.apool(); const apool = pad.apool();
const textLines = atext.text.slice(0, -1).split('\n'); const textLines = atext.text.slice(0, -1).split('\n');
const attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); const attribLines = splitAttributionLines(atext.attribs, atext.text);
const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough']; const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough'];
const anumMap: MapType = {}; const anumMap: MapType = {};
const css = ''; const css = '';
props.forEach((propName, i) => { props.forEach((propName, i) => {
// @ts-ignore
const propTrueNum = apool.putAttrib([propName, true], true); const propTrueNum = apool.putAttrib([propName, true], true);
if (propTrueNum >= 0) { if (propTrueNum >= 0) {
anumMap[propTrueNum] = i; anumMap[propTrueNum] = i;
@ -69,8 +73,8 @@ const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => {
// <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i> // <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i>
// becomes // becomes
// <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i> // <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i>
const taker = Changeset.stringIterator(text); const taker = new StringIterator(text);
const assem = Changeset.stringAssembler(); const assem = new StringAssembler();
let idx = 0; let idx = 0;
@ -79,13 +83,13 @@ const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => {
return; return;
} }
const ops = Changeset.deserializeOps(Changeset.subattribution(attribs, idx, idx + numChars)); const ops = deserializeOps(subattribution(attribs, idx, idx + numChars));
idx += numChars; idx += numChars;
for (const o of ops) { for (const o of ops) {
let propChanged = false; let propChanged = false;
for (const a of attributes.decodeAttribString(o.attribs)) { for (const a of decodeAttribString(o.attribs)) {
if (a in anumMap) { if (a in anumMap) {
const i = anumMap[a] as number; // i = 0 => bold, etc. const i = anumMap[a] as number; // i = 0 => bold, etc.
@ -197,7 +201,7 @@ const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => {
for (let i = 0; i < textLines.length; i++) { for (let i = 0; i < textLines.length; i++) {
const line = _analyzeLine(textLines[i], attribLines[i], apool); const line = _analyzeLine(textLines[i], attribLines[i], apool);
let lineContent = getLineTXT(line.text, line.aline); let lineContent = getLineTXT(line.text as string, line.aline);
if (line.listTypeName === 'bullet') { if (line.listTypeName === 'bullet') {
lineContent = `* ${lineContent}`; // add a bullet lineContent = `* ${lineContent}`; // add a bullet
@ -210,11 +214,11 @@ const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => {
} }
} }
if (line.listLevel > 0) { if ((line.listLevel as number) > 0) {
for (let j = line.listLevel - 1; j >= 0; j--) { for (let j = (line.listLevel as number) - 1; j >= 0; j--) {
pieces.push('\t'); // tab indent list numbers.. pieces.push('\t'); // tab indent list numbers..
if (!listNumbers[line.listLevel]) { if (!listNumbers[(line.listLevel as number)]) {
listNumbers[line.listLevel] = 0; listNumbers[(line.listLevel as number)] = 0;
} }
} }
@ -232,23 +236,23 @@ const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => {
* To handle going back to 2.1 when prevListLevel is lower number * To handle going back to 2.1 when prevListLevel is lower number
* than current line.listLevel then reset the object value * than current line.listLevel then reset the object value
*/ */
if (line.listLevel < prevListLevel) { if ((line.listLevel as number) < prevListLevel!) {
delete listNumbers[prevListLevel]; delete listNumbers[(prevListLevel as number)];
} }
// @ts-ignore // @ts-ignore
listNumbers[line.listLevel]++; listNumbers[line.listLevel]++;
if (line.listLevel > 1) { if ((line.listLevel as number) > 1) {
let x = 1; let x = 1;
while (x <= line.listLevel - 1) { while (x <= (line.listLevel as number) - 1) {
// if it's undefined to avoid undefined.undefined.1 for 0.0.1 // if it's undefined to avoid undefined.undefined.1 for 0.0.1
if (!listNumbers[x]) listNumbers[x] = 0; if (!listNumbers[x]) listNumbers[x] = 0;
pieces.push(`${listNumbers[x]}.`); pieces.push(`${listNumbers[x]}.`);
x++; x++;
} }
} }
pieces.push(`${listNumbers[line.listLevel]}. `); pieces.push(`${listNumbers[(line.listLevel as number)]}. `);
prevListLevel = line.listLevel; prevListLevel = line.listLevel as number;
} }
pieces.push(lineContent, '\n'); pieces.push(lineContent, '\n');
@ -260,9 +264,8 @@ const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => {
return pieces.join(''); return pieces.join('');
}; };
exports.getTXTFromAtext = getTXTFromAtext;
exports.getPadTXTDocument = async (padId:string, revNum:string) => { export const getPadTXTDocument = async (padId:string, revNum:number) => {
const pad = await padManager.getPad(padId); const pad = await getPad(padId);
return getPadTXT(pad, revNum); return getPadTXT(pad, revNum);
}; };

View file

@ -1,7 +1,5 @@
'use strict'; 'use strict';
import {APool} from "../types/PadType";
/** /**
* 2014 John McLear (Etherpad Foundation / McLear Ltd) * 2014 John McLear (Etherpad Foundation / McLear Ltd)
* *
@ -19,29 +17,28 @@ import {APool} from "../types/PadType";
*/ */
import AttributePool from '../../static/js/AttributePool'; import AttributePool from '../../static/js/AttributePool';
const {Pad} = require('../db/Pad'); import Pad from '../db/Pad';
const Stream = require('./Stream'); import Stream from './Stream';
const authorManager = require('../db/AuthorManager'); import {addPad, doesAuthorExist} from '../db/AuthorManager';
const db = require('../db/DB'); import {aCallAll, callAll} from '../../static/js/pluginfw/hooks';
const hooks = require('../../static/js/pluginfw/hooks');
import log4js from 'log4js'; import log4js from 'log4js';
const supportedElems = require('../../static/js/contentcollector').supportedElems; const supportedElems = require('../../static/js/contentcollector').supportedElems;
import ueberdb from 'ueberdb2'; import ueberdb from 'ueberdb2';
const logger = log4js.getLogger('ImportEtherpad'); const logger = log4js.getLogger('ImportEtherpad');
exports.setPadRaw = async (padId: string, r: string, authorId = '') => { export const setPadRaw = async (padId: string, r: string, authorId = '') => {
const records = JSON.parse(r); const records = JSON.parse(r);
// get supported block Elements from plugins, we will use this later. // get supported block Elements from plugins, we will use this later.
hooks.callAll('ccRegisterBlockElements').forEach((element:any) => { callAll('ccRegisterBlockElements').forEach((element:any) => {
supportedElems.add(element); supportedElems.add(element);
}); });
// DB key prefixes for pad records. Each key is expected to have the form `${prefix}:${padId}` or // DB key prefixes for pad records. Each key is expected to have the form `${prefix}:${padId}` or
// `${prefix}:${padId}:${otherstuff}`. // `${prefix}:${padId}:${otherstuff}`.
const padKeyPrefixes = [ const padKeyPrefixes = [
...await hooks.aCallAll('exportEtherpadAdditionalContent'), ...await aCallAll('exportEtherpadAdditionalContent'),
'pad', 'pad',
]; ];
@ -55,7 +52,7 @@ exports.setPadRaw = async (padId: string, r: string, authorId = '') => {
// there is a problem with the data. // there is a problem with the data.
const data = new Map(); const data = new Map();
const existingAuthors = new Set(); const existingAuthors = new Set<string>();
const padDb = new ueberdb.Database('memory', {data}); const padDb = new ueberdb.Database('memory', {data});
await padDb.init(); await padDb.init();
try { try {
@ -74,7 +71,7 @@ exports.setPadRaw = async (padId: string, r: string, authorId = '') => {
throw new TypeError('globalAuthor padIDs subkey is not a string'); throw new TypeError('globalAuthor padIDs subkey is not a string');
} }
checkOriginalPadId(value.padIDs); checkOriginalPadId(value.padIDs);
if (await authorManager.doesAuthorExist(id)) { if (await doesAuthorExist(id)) {
existingAuthors.add(id); existingAuthors.add(id);
return; return;
} }
@ -106,9 +103,9 @@ exports.setPadRaw = async (padId: string, r: string, authorId = '') => {
const readOps = new Stream(Object.entries(records)).map(([k, v]) => processRecord(k, v)); const readOps = new Stream(Object.entries(records)).map(([k, v]) => processRecord(k, v));
for (const op of readOps.batch(100).buffer(99)) await op; for (const op of readOps.batch(100).buffer(99)) await op;
const pad = new Pad(padId, padDb); const pad = new Pad(padId);
await pad.init(null, authorId); await pad.init(null, authorId);
await hooks.aCallAll('importEtherpad', { await aCallAll('importEtherpad', {
pad, pad,
// Shallow freeze meant to prevent accidental bugs. It would be better to deep freeze, but // Shallow freeze meant to prevent accidental bugs. It would be better to deep freeze, but
// it's not worth the added complexity. // it's not worth the added complexity.
@ -121,8 +118,8 @@ exports.setPadRaw = async (padId: string, r: string, authorId = '') => {
} }
const writeOps = (function* () { const writeOps = (function* () {
for (const [k, v] of data) yield db.set(k, v); for (const [k, v] of data) yield padDb.set(k, v);
for (const a of existingAuthors) yield authorManager.addPad(a, padId); for (const a of existingAuthors) yield addPad(a, padId);
})(); })();
for (const op of new Stream(writeOps).batch(100).buffer(99)) await op; for (const op of new Stream(writeOps).batch(100).buffer(99)) await op;
}; };

View file

@ -16,15 +16,16 @@
*/ */
import log4js from 'log4js'; import log4js from 'log4js';
const Changeset = require('../../static/js/Changeset'); import {deserializeOps} from '../../static/js/Changeset';
const contentcollector = require('../../static/js/contentcollector'); import ContentCollector from '../../static/js/contentcollector';
import jsdom from 'jsdom'; import jsdom from 'jsdom';
import {PadType} from "../types/PadType"; import Pad from "../db/Pad";
import {Builder} from "../../static/js/Builder";
const apiLogger = log4js.getLogger('ImportHtml'); const apiLogger = log4js.getLogger('ImportHtml');
let processor:any; let processor:any;
exports.setPadHTML = async (pad: PadType, html:string, authorId = '') => { export const setPadHTML = async (pad: Pad, html:string, authorId = '') => {
if (processor == null) { if (processor == null) {
const [{rehype}, {default: minifyWhitespace}] = const [{rehype}, {default: minifyWhitespace}] =
await Promise.all([import('rehype'), import('rehype-minify-whitespace')]); await Promise.all([import('rehype'), import('rehype-minify-whitespace')]);
@ -43,7 +44,7 @@ exports.setPadHTML = async (pad: PadType, html:string, authorId = '') => {
// Convert a dom tree into a list of lines and attribute liens // Convert a dom tree into a list of lines and attribute liens
// using the content collector object // using the content collector object
const cc = contentcollector.makeContentCollector(true, null, pad.pool); const cc = new ContentCollector(true, null, pad.pool);
try { try {
// we use a try here because if the HTML is bad it will blow up // we use a try here because if the HTML is bad it will blow up
cc.collectContent(document.body); cc.collectContent(document.body);
@ -69,13 +70,13 @@ exports.setPadHTML = async (pad: PadType, html:string, authorId = '') => {
const newAttribs = `${result.lineAttribs.join('|1+1')}|1+1`; const newAttribs = `${result.lineAttribs.join('|1+1')}|1+1`;
// create a new changeset with a helper builder object // create a new changeset with a helper builder object
const builder = Changeset.builder(1); const builder = new Builder(1);
// assemble each line into the builder // assemble each line into the builder
let textIndex = 0; let textIndex = 0;
const newTextStart = 0; const newTextStart = 0;
const newTextEnd = newText.length; const newTextEnd = newText.length;
for (const op of Changeset.deserializeOps(newAttribs)) { for (const op of deserializeOps(newAttribs)) {
const nextIndex = textIndex + op.chars; const nextIndex = textIndex + op.chars;
if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {
const start = Math.max(newTextStart, textIndex); const start = Math.max(newTextStart, textIndex);

View file

@ -17,12 +17,12 @@
* limitations under the License. * limitations under the License.
*/ */
const async = require('async'); import async from 'async';
const fs = require('fs').promises; import {promises as fs} from 'fs'
const log4js = require('log4js'); import log4js from 'log4js';
const os = require('os'); import os from 'os';
const path = require('path'); import path from 'path';
const runCmd = require('./run_cmd'); import runCmd from './run_cmd';
const settings = require('./Settings'); const settings = require('./Settings');
const logger = log4js.getLogger('LibreOffice'); const logger = log4js.getLogger('LibreOffice');
@ -54,25 +54,25 @@ const doConvertTask = async (task:{
// @ts-ignore // @ts-ignore
(line) => logger.error(`[${p.child.pid}] stderr: ${line}`), (line) => logger.error(`[${p.child.pid}] stderr: ${line}`),
]}); ]});
logger.info(`[${p.child.pid}] Converting ${task.srcFile} to ${task.type} in ${tmpDir}`); logger.info(`[${p.child!.pid}] Converting ${task.srcFile} to ${task.type} in ${tmpDir}`);
// Soffice/libreoffice is buggy and often hangs. // Soffice/libreoffice is buggy and often hangs.
// To remedy this we kill the spawned process after a while. // To remedy this we kill the spawned process after a while.
// TODO: Use the timeout option once support for Node.js < v15.13.0 is dropped. // TODO: Use the timeout option once support for Node.js < v15.13.0 is dropped.
const hangTimeout = setTimeout(() => { const hangTimeout = setTimeout(() => {
logger.error(`[${p.child.pid}] Conversion timed out; killing LibreOffice...`); logger.error(`[${p.child!.pid}] Conversion timed out; killing LibreOffice...`);
p.child.kill(); p.child!.kill();
}, 120000); }, 120000);
try { try {
await p; await p;
} catch (err:any) { } catch (err:any) {
logger.error(`[${p.child.pid}] Conversion failed: ${err.stack || err}`); logger.error(`[${p.child!.pid}] Conversion failed: ${err.stack || err}`);
throw err; throw err;
} finally { } finally {
clearTimeout(hangTimeout); clearTimeout(hangTimeout);
} }
logger.info(`[${p.child.pid}] Conversion done.`); logger.info(`[${p.child!.pid}] Conversion done.`);
const filename = path.basename(task.srcFile); const filename = path.basename(task.srcFile);
const sourceFile = `${filename.substr(0, filename.lastIndexOf('.'))}.${task.fileExtension}`; const sourceFile = `${filename.substring(0, filename.lastIndexOf('.'))}.${task.fileExtension}`;
const sourcePath = path.join(tmpDir, sourceFile); const sourcePath = path.join(tmpDir, sourceFile);
logger.debug(`Renaming ${sourcePath} to ${task.destFile}`); logger.debug(`Renaming ${sourcePath} to ${task.destFile}`);
await fs.rename(sourcePath, task.destFile); await fs.rename(sourcePath, task.destFile);
@ -89,7 +89,7 @@ const queue = async.queue(doConvertTask, 1);
* @param {String} type The type to convert into * @param {String} type The type to convert into
* @param {Function} callback Standard callback function * @param {Function} callback Standard callback function
*/ */
exports.convertFile = async (srcFile: string, destFile: string, type:string) => { export const convertFile = async (srcFile: string, destFile: string, type:string) => {
// Used for the moving of the file, not the conversion // Used for the moving of the file, not the conversion
const fileExtension = type; const fileExtension = type;

View file

@ -19,14 +19,14 @@
* limitations under the License. * limitations under the License.
*/ */
const semver = require('semver'); import semver from 'semver';
/** /**
* Quits if Etherpad is not running on a given minimum Node version * Quits if Etherpad is not running on a given minimum Node version
* *
* @param {String} minNodeVersion Minimum required Node version * @param {String} minNodeVersion Minimum required Node version
*/ */
exports.enforceMinNodeVersion = (minNodeVersion: string) => { export const enforceMinNodeVersion = (minNodeVersion: string) => {
const currentNodeVersion = process.version; const currentNodeVersion = process.version;
// we cannot use template literals, since we still do not know if we are // we cannot use template literals, since we still do not know if we are
@ -49,7 +49,7 @@ exports.enforceMinNodeVersion = (minNodeVersion: string) => {
* @param {Function} epRemovalVersion Etherpad version that will remove support for deprecated * @param {Function} epRemovalVersion Etherpad version that will remove support for deprecated
* Node releases * Node releases
*/ */
exports.checkDeprecationStatus = (lowestNonDeprecatedNodeVersion: string, epRemovalVersion:Function) => { export const checkDeprecationStatus = (lowestNonDeprecatedNodeVersion: string, epRemovalVersion:string) => {
const currentNodeVersion = process.version; const currentNodeVersion = process.version;
if (semver.lt(currentNodeVersion, lowestNonDeprecatedNodeVersion)) { if (semver.lt(currentNodeVersion, lowestNonDeprecatedNodeVersion)) {

View file

@ -28,123 +28,111 @@
*/ */
import {MapArrayType} from "../types/MapType"; import {MapArrayType} from "../types/MapType";
import {SettingsNode, SettingsTree} from "./SettingsTree"; import {SettingsNode} from "./SettingsTree";
import {coerce} from "semver"; import {version} from '../../package.json'
import {findEtherpadRoot, isSubdir, makeAbsolute} from './AbsolutePaths';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {argvP} from "./Cli";
import jsonminify from 'jsonminify';
import log4js from 'log4js';
import {randomString} from './randomstring';
const absolutePaths = require('./AbsolutePaths'); import _ from 'underscore';
const deepEqual = require('fast-deep-equal/es6');
const fs = require('fs');
const os = require('os');
const path = require('path');
const argv = require('./Cli').argv;
const jsonminify = require('jsonminify');
const log4js = require('log4js');
const randomString = require('./randomstring');
const suppressDisableMsg = ' -- To suppress these warning messages change ' +
'suppressErrorsInPadText to true in your settings.json\n';
const _ = require('underscore');
const logger = log4js.getLogger('settings');
// Exported values that settings.json and credentials.json cannot override.
const nonSettings = [
'credentialsFilename',
'settingsFilename',
];
// This is a function to make it easy to create a new instance. It is important to not reuse a class Settings {
// config object after passing it to log4js.configure() because that method mutates the object. :(
const defaultLogConfig = (level: string) => ({
appenders: {console: {type: 'console'}},
categories: {
default: {appenders: ['console'], level},
}
});
const defaultLogLevel = 'INFO';
const initLogging = (config: any) => {
// log4js.configure() modifies exports.logconfig so check for equality first.
log4js.configure(config);
log4js.getLogger('console');
// Overwrites for console output methods
console.debug = logger.debug.bind(logger);
console.log = logger.info.bind(logger);
console.warn = logger.warn.bind(logger);
console.error = logger.error.bind(logger);
};
constructor() {
// Initialize logging as early as possible with reasonable defaults. Logging will be re-initialized // Initialize logging as early as possible with reasonable defaults. Logging will be re-initialized
// with the user's chosen log level and logger config after the settings have been loaded. // with the user's chosen log level and logger config after the settings have been loaded.
initLogging(defaultLogConfig(defaultLogLevel)); this.initLogging(this.defaultLogConfig(this.defaultLogLevel));
this.logger.info('All relative paths will be interpreted relative to the identified ' +
/* Root path of the installation */ `Etherpad base dir: ${this.root}`);
exports.root = absolutePaths.findEtherpadRoot(); // initially load settings
logger.info('All relative paths will be interpreted relative to the identified ' + this.reloadSettings();
`Etherpad base dir: ${exports.root}`); }
exports.settingsFilename = absolutePaths.makeAbsolute(argv.settings || 'settings.json');
exports.credentialsFilename = absolutePaths.makeAbsolute(argv.credentials || 'credentials.json');
/** /**
* The app title, visible e.g. in the browser window * The app title, visible e.g. in the browser window
*/ */
exports.title = 'Etherpad'; private title = 'Etherpad';
private settingsFilename = makeAbsolute(argvP.settings || 'settings.json');
private credentialsFilename = makeAbsolute(argvP.credentials || 'credentials.json');
private suppressDisableMsg = ' -- To suppress these warning messages change ' +
'suppressErrorsInPadText to true in your settings.json\n';
private defaultLogLevel = 'INFO';
private logger = log4js.getLogger('settings');
/* Root path of the installation */
private root = findEtherpadRoot();
/** /**
* Pathname of the favicon you want to use. If null, the skin's favicon is * Pathname of the favicon you want to use. If null, the skin's favicon is
* used if one is provided by the skin, otherwise the default Etherpad favicon * used if one is provided by the skin, otherwise the default Etherpad favicon
* is used. If this is a relative path it is interpreted as relative to the * is used. If this is a relative path it is interpreted as relative to the
* Etherpad root directory. * Etherpad root directory.
*/ */
exports.favicon = null; private favicon: string|null = null;
// Exported values that settings.json and credentials.json cannot override.
exports.ttl = { private nonSettings = [
AccessToken: 1 * 60 * 60, // 1 hour in seconds 'credentialsFilename',
AuthorizationCode: 10 * 60, // 10 minutes in seconds 'settingsFilename',
ClientCredentials: 1 * 60 * 60, // 1 hour in seconds ]
IdToken: 1 * 60 * 60, // 1 hour in seconds
RefreshToken: 1 * 24 * 60 * 60, // 1 day in seconds
}
/* /*
* Skin name. * Skin name.
* *
* Initialized to null, so we can spot an old configuration file and invite the * Initialized to null, so we can spot an old configuration file and invite the
* user to update it before falling back to the default. * user to update it before falling back to the default.
*/ */
exports.skinName = null; skinName: string | null = null;
skinVariants = 'super-light-toolbar super-light-editor light-background';
exports.skinVariants = 'super-light-toolbar super-light-editor light-background'; private ttl = {
AccessToken: 1 * 60 * 60, // 1 hour in seconds
AuthorizationCode: 10 * 60, // 10 minutes in seconds
ClientCredentials: 1 * 60 * 60, // 1 hour in seconds
IdToken: 1 * 60 * 60, // 1 hour in seconds
RefreshToken: 1 * 24 * 60 * 60, // 1 day in seconds
}
/** /**
* The IP ep-lite should listen to * Should we suppress Error messages from being in Pad Contents
*/ */
exports.ip = '0.0.0.0'; private suppressErrorsInPadText = false;
/** /**
* The Port ep-lite should listen to * The Port ep-lite should listen to
*/ */
exports.port = process.env.PORT || 9001; port = process.env.PORT as unknown as number || 9001;
/** /**
* Should we suppress Error messages from being in Pad Contents * The IP ep-lite should listen to
*/ */
exports.suppressErrorsInPadText = false; ip: string = '0.0.0.0';
// This is a function to make it easy to create a new instance. It is important to not reuse a
// config object after passing it to log4js.configure() because that method mutates the object. :(
private defaultLogConfig = (level: string) => ({
appenders: {console: {type: 'console'}},
categories: {
default: {appenders: ['console'], level},
}
})
/** /**
* The SSL signed server key and the Certificate Authority's own certificate * The SSL signed server key and the Certificate Authority's own certificate
* default case: ep-lite does *not* use SSL. A signed server key is not required in this case. * default case: ep-lite does *not* use SSL. A signed server key is not required in this case.
*/ */
exports.ssl = false; ssl:{
key:string,
cert:string
ca?: string[]
}|false = false;
/** /**
* socket.io transport methods * socket.io transport methods
**/ **/
exports.socketTransportProtocols = ['websocket', 'polling']; private socketTransportProtocols = ['websocket', 'polling'];
private socketIo = {
exports.socketIo = {
/** /**
* Maximum permitted client message size (in bytes). * Maximum permitted client message size (in bytes).
* *
@ -156,28 +144,25 @@ exports.socketIo = {
maxHttpBufferSize: 50000, maxHttpBufferSize: 50000,
}; };
/* /*
The authentication method used by the server. The authentication method used by the server.
The default value is sso The default value is sso
If you want to use the old authentication system, change this to apikey If you want to use the old authentication system, change this to apikey
*/ */
exports.authenticationMethod = 'sso' private authenticationMethod = 'sso'
/* /*
* The Type of the database * The Type of the database
*/ */
exports.dbType = 'dirty'; private dbType = 'dirty';
/** /**
* This setting is passed with dbType to ueberDB to set up the database * This setting is passed with dbType to ueberDB to set up the database
*/ */
exports.dbSettings = {filename: path.join(exports.root, 'var/dirty.db')}; private dbSettings = {filename: path.join(this.root, 'var/dirty.db')};
/** /**
* The default Text of a new pad * The default Text of a new pad
*/ */
exports.defaultPadText = [ private defaultPadText = [
'Welcome to Etherpad!', 'Welcome to Etherpad!',
'', '',
'This pad text is synchronized as you type, so that everyone viewing this page sees the same ' + 'This pad text is synchronized as you type, so that everyone viewing this page sees the same ' +
@ -186,10 +171,11 @@ exports.defaultPadText = [
'Etherpad on Github: https://github.com/ether/etherpad-lite', 'Etherpad on Github: https://github.com/ether/etherpad-lite',
].join('\n'); ].join('\n');
/** /**
* The default Pad Settings for a user (Can be overridden by changing the setting * The default Pad Settings for a user (Can be overridden by changing the setting
*/ */
exports.padOptions = { padOptions = {
noColors: false, noColors: false,
showControls: true, showControls: true,
showChat: true, showChat: true,
@ -206,7 +192,7 @@ exports.padOptions = {
/** /**
* Whether certain shortcut keys are enabled for a user in the pad * Whether certain shortcut keys are enabled for a user in the pad
*/ */
exports.padShortcutEnabled = { padShortcutEnabled = {
altF9: true, altF9: true,
altC: true, altC: true,
delete: true, delete: true,
@ -234,7 +220,7 @@ exports.padShortcutEnabled = {
/** /**
* The toolbar buttons and order. * The toolbar buttons and order.
*/ */
exports.toolbar = { private toolbar = {
left: [ left: [
['bold', 'italic', 'underline', 'strikethrough'], ['bold', 'italic', 'underline', 'strikethrough'],
['orderedlist', 'unorderedlist', 'indent', 'outdent'], ['orderedlist', 'unorderedlist', 'indent', 'outdent'],
@ -254,87 +240,89 @@ exports.toolbar = {
/** /**
* A flag that requires any user to have a valid session (via the api) before accessing a pad * A flag that requires any user to have a valid session (via the api) before accessing a pad
*/ */
exports.requireSession = false; private requireSession = false;
/** /**
* A flag that prevents users from creating new pads * A flag that prevents users from creating new pads
*/ */
exports.editOnly = false; private editOnly = false;
/** /**
* Max age that responses will have (affects caching layer). * Max age that responses will have (affects caching layer).
*/ */
exports.maxAge = 1000 * 60 * 60 * 6; // 6 hours private maxAge = 1000 * 60 * 60 * 6; // 6 hours
/** /**
* A flag that shows if minification is enabled or not * A flag that shows if minification is enabled or not
*/ */
exports.minify = true; private minify = true;
/** /**
* The path of the abiword executable * The path of the abiword executable
*/ */
exports.abiword = null; private abiword = null;
/** /**
* The path of the libreoffice executable * The path of the libreoffice executable
*/ */
exports.soffice = null; private soffice = null;
/** /**
* Should we support none natively supported file types on import? * Should we support none natively supported file types on import?
*/ */
exports.allowUnknownFileEnds = true; private allowUnknownFileEnds = true;
/** /**
* The log level of log4js * The log level of log4js
*/ */
exports.loglevel = defaultLogLevel; private loglevel: string = this.defaultLogLevel;
/** /**
* Disable IP logging * Disable IP logging
*/ */
exports.disableIPlogging = false; disableIPlogging = false;
/** /**
* Number of seconds to automatically reconnect pad * Number of seconds to automatically reconnect pad
*/ */
exports.automaticReconnectionTimeout = 0; automaticReconnectionTimeout = 0;
/** /**
* Disable Load Testing * Disable Load Testing
*/ */
exports.loadTest = false; private loadTest = false;
/** /**
* Disable dump of objects preventing a clean exit * Disable dump of objects preventing a clean exit
*/ */
exports.dumpOnUncleanExit = false; dumpOnUncleanExit = false;
/** /**
* Enable indentation on new lines * Enable indentation on new lines
*/ */
exports.indentationOnNewLine = true; indentationOnNewLine = true;
/* /*
* log4js appender configuration * log4js appender configuration
*/ */
exports.logconfig = null; private logconfig: { categories: { default: { level: string, appenders: string[] } }, appenders: { console: { type: string } } } | null = null;
/* /*
* Deprecated cookie signing key. * Deprecated cookie signing key.
*/ */
exports.sessionKey = null; sessionKey: string|null = null;
/* /*
* Trust Proxy, whether or not trust the x-forwarded-for header. * Trust Proxy, whether or not trust the x-forwarded-for header.
*/ */
exports.trustProxy = false; trustProxy = false;
/* /*
* Settings controlling the session cookie issued by Etherpad. * Settings controlling the session cookie issued by Etherpad.
*/ */
exports.cookie = { cookie = {
keyRotationInterval: 1 * 24 * 60 * 60 * 1000, keyRotationInterval: 1 * 24 * 60 * 60 * 1000,
/* /*
* Value of the SameSite cookie property. "Lax" is recommended unless * Value of the SameSite cookie property. "Lax" is recommended unless
@ -357,27 +345,27 @@ exports.cookie = {
* authorization. Note: /admin always requires authentication, and * authorization. Note: /admin always requires authentication, and
* either authorization by a module, or a user with is_admin set * either authorization by a module, or a user with is_admin set
*/ */
exports.requireAuthentication = false; private requireAuthentication = false;
exports.requireAuthorization = false; private requireAuthorization = false;
exports.users = {}; users = {};
/* /*
* This setting is used for configuring sso * This setting is used for configuring sso
*/ */
exports.sso = { private sso = {
issuer: "http://localhost:9001" issuer: "http://localhost:9001"
} }
/* /*
* Show settings in admin page, by default it is true * Show settings in admin page, by default it is true
*/ */
exports.showSettingsInAdminPage = true; private showSettingsInAdminPage = true;
/* /*
* By default, when caret is moved out of viewport, it scrolls the minimum * By default, when caret is moved out of viewport, it scrolls the minimum
* height needed to make this line visible. * height needed to make this line visible.
*/ */
exports.scrollWhenFocusLineIsOutOfViewport = { scrollWhenFocusLineIsOutOfViewport = {
/* /*
* Percentage of viewport height to be additionally scrolled. * Percentage of viewport height to be additionally scrolled.
*/ */
@ -410,12 +398,12 @@ exports.scrollWhenFocusLineIsOutOfViewport = {
* *
* Do not enable on production machines. * Do not enable on production machines.
*/ */
exports.exposeVersion = false; exposeVersion = false;
/* /*
* Override any strings found in locale directories * Override any strings found in locale directories
*/ */
exports.customLocaleStrings = {}; private customLocaleStrings = {};
/* /*
* From Etherpad 1.8.3 onwards, import and export of pads is always rate * From Etherpad 1.8.3 onwards, import and export of pads is always rate
@ -426,7 +414,7 @@ exports.customLocaleStrings = {};
* *
* See https://github.com/nfriedly/express-rate-limit for more options * See https://github.com/nfriedly/express-rate-limit for more options
*/ */
exports.importExportRateLimiting = { private importExportRateLimiting = {
// duration of the rate limit window (milliseconds) // duration of the rate limit window (milliseconds)
windowMs: 90000, windowMs: 90000,
@ -442,7 +430,7 @@ exports.importExportRateLimiting = {
* *
* See https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#websocket-single-connection-prevent-flooding for more options * See https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#websocket-single-connection-prevent-flooding for more options
*/ */
exports.commitRateLimiting = { commitRateLimiting = {
// duration of the rate limit window (seconds) // duration of the rate limit window (seconds)
duration: 1, duration: 1,
@ -456,39 +444,53 @@ exports.commitRateLimiting = {
* *
* File size is specified in bytes. Default is 50 MB. * File size is specified in bytes. Default is 50 MB.
*/ */
exports.importMaxFileSize = 50 * 1024 * 1024; private importMaxFileSize = 50 * 1024 * 1024;
/* /*
* Disable Admin UI tests * Disable Admin UI tests
*/ */
exports.enableAdminUITests = false; enableAdminUITests = false;
/* /*
* Enable auto conversion of pad Ids to lowercase. * Enable auto conversion of pad Ids to lowercase.
* e.g. /p/EtHeRpAd to /p/etherpad * e.g. /p/EtHeRpAd to /p/etherpad
*/ */
exports.lowerCasePadIds = false; private lowerCasePadIds = false;
randomVersionString: string|null = null;
private initLogging = (config: any) => {
// log4js.configure() modifies logconfig so check for equality first.
log4js.configure(config);
log4js.getLogger('console');
// Overwrites for console output methods
console.debug = this.logger.debug.bind(this.logger);
console.log = this.logger.info.bind(this.logger);
console.warn = this.logger.warn.bind(this.logger);
console.error = this.logger.error.bind(this.logger);
}
// checks if abiword is avaiable // checks if abiword is avaiable
exports.abiwordAvailable = () => { abiwordAvailable = () => {
if (exports.abiword != null) { if (this.abiword != null) {
return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes'; return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes';
} else { } else {
return 'no'; return 'no';
} }
}; };
exports.sofficeAvailable = () => { sofficeAvailable = () => {
if (exports.soffice != null) { if (this.soffice != null) {
return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes'; return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes';
} else { } else {
return 'no'; return 'no';
} }
}; };
exports.exportAvailable = () => { exportAvailable = () => {
const abiword = exports.abiwordAvailable(); const abiword = this.abiwordAvailable();
const soffice = exports.sofficeAvailable(); const soffice = this.sofficeAvailable();
if (abiword === 'no' && soffice === 'no') { if (abiword === 'no' && soffice === 'no') {
return 'no'; return 'no';
@ -501,13 +503,13 @@ exports.exportAvailable = () => {
}; };
// Provide git version if available // Provide git version if available
exports.getGitCommit = () => { getGitCommit = () => {
let version = ''; let version = '';
try { try {
let rootPath = exports.root; let rootPath = this.root;
if (fs.lstatSync(`${rootPath}/.git`).isFile()) { if (fs.lstatSync(`${rootPath}/.git`).isFile()) {
rootPath = fs.readFileSync(`${rootPath}/.git`, 'utf8'); rootPath = fs.readFileSync(`${rootPath}/.git`, 'utf8');
rootPath = rootPath.split(' ').pop().trim(); rootPath = rootPath.split(' ').pop()!.trim();
} else { } else {
rootPath += '/.git'; rootPath += '/.git';
} }
@ -520,15 +522,13 @@ exports.getGitCommit = () => {
} }
version = version.substring(0, 7); version = version.substring(0, 7);
} catch (e: any) { } catch (e: any) {
logger.warn(`Can't get git version for server header\n${e.message}`); this.logger.warn(`Can't get git version for server header\n${e.message}`);
} }
return version; return version;
}; }
// Return etherpad version from package.json // Return etherpad version from package.json
exports.getEpVersion = () => require('../../package.json').version; getEpVersion = () => version;
/** /**
* Receives a settingsObj and, if the property name is a valid configuration * Receives a settingsObj and, if the property name is a valid configuration
@ -537,32 +537,37 @@ exports.getEpVersion = () => require('../../package.json').version;
* This code refactors a previous version that copied & pasted the same code for * This code refactors a previous version that copied & pasted the same code for
* both "settings.json" and "credentials.json". * both "settings.json" and "credentials.json".
*/ */
const storeSettings = (settingsObj: any) => { private storeSettings = (settingsObj: any) => {
for (const i of Object.keys(settingsObj || {})) { for (const i of Object.keys(settingsObj || {})) {
if (nonSettings.includes(i)) { if (this.nonSettings.includes(i)) {
logger.warn(`Ignoring setting: '${i}'`); this.logger.warn(`Ignoring setting: '${i}'`);
continue; continue;
} }
// test if the setting starts with a lowercase character // test if the setting starts with a lowercase character
if (i.charAt(0).search('[a-z]') !== 0) { if (i.charAt(0).search('[a-z]') !== 0) {
logger.warn(`Settings should start with a lowercase character: '${i}'`); this.logger.warn(`Settings should start with a lowercase character: '${i}'`);
} }
// we know this setting, so we overwrite it // we know this setting, so we overwrite it
// or it's a settings hash, specific to a plugin // or it's a settings hash, specific to a plugin
if (exports[i] !== undefined || i.indexOf('ep_') === 0) { // @ts-ignore
if (this[i] !== undefined || i.indexOf('ep_') === 0) {
if (_.isObject(settingsObj[i]) && !Array.isArray(settingsObj[i])) { if (_.isObject(settingsObj[i]) && !Array.isArray(settingsObj[i])) {
exports[i] = _.defaults(settingsObj[i], exports[i]); // @ts-ignore
this[i] = _.defaults(settingsObj[i], exports[i]);
} else { } else {
exports[i] = settingsObj[i]; // @ts-ignore
this[i] = settingsObj[i];
} }
} else { } else {
// this setting is unknown, output a warning and throw it away // this setting is unknown, output a warning and throw it away
logger.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`); this.logger.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`);
} }
} }
}; }
/* /*
* If stringValue is a numeric string, or its value is "true" or "false", coerce * If stringValue is a numeric string, or its value is "true" or "false", coerce
@ -576,7 +581,7 @@ const storeSettings = (settingsObj: any) => {
* short syntax "${ABIWORD}", and not "${ABIWORD:null}": the latter would result * short syntax "${ABIWORD}", and not "${ABIWORD:null}": the latter would result
* in the literal string "null", instead. * in the literal string "null", instead.
*/ */
const coerceValue = (stringValue: string) => { private coerceValue = (stringValue: string) => {
// cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number
// @ts-ignore // @ts-ignore
const isNumeric = !isNaN(stringValue) && !isNaN(parseFloat(stringValue) && isFinite(stringValue)); const isNumeric = !isNaN(stringValue) && !isNaN(parseFloat(stringValue) && isFinite(stringValue));
@ -637,7 +642,7 @@ const coerceValue = (stringValue: string) => {
* *
* see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter * see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter
*/ */
const lookupEnvironmentVariables = (obj: MapArrayType<any>) => { private lookupEnvironmentVariables = (obj: MapArrayType<any>) => {
const replaceEnvs = (obj: MapArrayType<any>) => { const replaceEnvs = (obj: MapArrayType<any>) => {
for (let [key, value] of Object.entries(obj)) { for (let [key, value] of Object.entries(obj)) {
/* /*
@ -696,7 +701,7 @@ const lookupEnvironmentVariables = (obj: MapArrayType<any>) => {
const defaultValue = match[3]; const defaultValue = match[3];
if ((envVarValue === undefined) && (defaultValue === undefined)) { if ((envVarValue === undefined) && (defaultValue === undefined)) {
logger.warn(`Environment variable "${envVarName}" does not contain any value for ` + this.logger.warn(`Environment variable "${envVarName}" does not contain any value for ` +
`configuration key "${key}", and no default was given. Using null. ` + `configuration key "${key}", and no default was given. Using null. ` +
'THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF ETHERPAD; you should ' + 'THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF ETHERPAD; you should ' +
'explicitly use "null" as the default if you want to continue to use null.'); 'explicitly use "null" as the default if you want to continue to use null.');
@ -710,10 +715,10 @@ const lookupEnvironmentVariables = (obj: MapArrayType<any>) => {
} }
if ((envVarValue === undefined) && (defaultValue !== undefined)) { if ((envVarValue === undefined) && (defaultValue !== undefined)) {
logger.debug(`Environment variable "${envVarName}" not found for ` + this.logger.debug(`Environment variable "${envVarName}" not found for ` +
`configuration key "${key}". Falling back to default value.`); `configuration key "${key}". Falling back to default value.`);
obj[key] = coerceValue(defaultValue); obj[key] = this.coerceValue(defaultValue);
continue continue
} }
@ -723,10 +728,10 @@ const lookupEnvironmentVariables = (obj: MapArrayType<any>) => {
* For numeric and boolean strings let's convert it to proper types before * For numeric and boolean strings let's convert it to proper types before
* returning it, in order to maintain backward compatibility. * returning it, in order to maintain backward compatibility.
*/ */
logger.debug( this.logger.debug(
`Configuration key "${key}" will be read from environment variable "${envVarName}"`); `Configuration key "${key}" will be read from environment variable "${envVarName}"`);
obj[key] = coerceValue(envVarValue!); obj[key] = this.coerceValue(envVarValue!);
} }
return obj return obj
} }
@ -758,7 +763,8 @@ const lookupEnvironmentVariables = (obj: MapArrayType<any>) => {
const rooting = root.collectFromLeafsUpwards() const rooting = root.collectFromLeafsUpwards()
obj = Object.assign(obj, rooting) obj = Object.assign(obj, rooting)
return obj; return obj;
}; }
/** /**
@ -769,7 +775,7 @@ const lookupEnvironmentVariables = (obj: MapArrayType<any>) => {
* *
* The isSettings variable only controls the error logging. * The isSettings variable only controls the error logging.
*/ */
const parseSettings = (settingsFilename: string, isSettings: boolean) => { private parseSettings = (settingsFilename: string, isSettings: boolean) => {
let settingsStr = ''; let settingsStr = '';
let settingsType, notFoundMessage, notFoundFunction; let settingsType, notFoundMessage, notFoundFunction;
@ -777,11 +783,11 @@ const parseSettings = (settingsFilename: string, isSettings: boolean) => {
if (isSettings) { if (isSettings) {
settingsType = 'settings'; settingsType = 'settings';
notFoundMessage = 'Continuing using defaults!'; notFoundMessage = 'Continuing using defaults!';
notFoundFunction = logger.warn.bind(logger); notFoundFunction = this.logger.warn.bind(this.logger);
} else { } else {
settingsType = 'credentials'; settingsType = 'credentials';
notFoundMessage = 'Ignoring.'; notFoundMessage = 'Ignoring.';
notFoundFunction = logger.info.bind(logger); notFoundFunction = this.logger.info.bind(this.logger);
} }
try { try {
@ -799,140 +805,137 @@ const parseSettings = (settingsFilename: string, isSettings: boolean) => {
const settings = JSON.parse(settingsStr); const settings = JSON.parse(settingsStr);
logger.info(`${settingsType} loaded from: ${settingsFilename}`); this.logger.info(`${settingsType} loaded from: ${settingsFilename}`);
return lookupEnvironmentVariables(settings); return this.lookupEnvironmentVariables(settings);
} catch (e: any) { } catch (e: any) {
logger.error(`There was an error processing your ${settingsType} ` + this.logger.error(`There was an error processing your ${settingsType} ` +
`file from ${settingsFilename}: ${e.message}`); `file from ${settingsFilename}: ${e.message}`);
process.exit(1); process.exit(1);
} }
};
exports.reloadSettings = () => {
const settings = parseSettings(exports.settingsFilename, true);
const credentials = parseSettings(exports.credentialsFilename, false);
storeSettings(settings);
storeSettings(credentials);
// Init logging config
exports.logconfig = defaultLogConfig(exports.loglevel ? exports.loglevel : defaultLogLevel);
initLogging(exports.logconfig);
if (!exports.skinName) {
logger.warn('No "skinName" parameter found. Please check out settings.json.template and ' +
'update your settings.json. Falling back to the default "colibris".');
exports.skinName = 'colibris';
} }
if (!exports.socketTransportProtocols.includes("websocket") || exports.socketTransportProtocols.includes("polling")) { private reloadSettings = () => {
logger.warn("Invalid socketTransportProtocols setting. Please check out settings.json.template and update your settings.json. Falling back to the default ['websocket', 'polling']."); const settings = this.parseSettings(this.settingsFilename, true);
exports.socketTransportProtocols = ['websocket', 'polling']; const credentials = this.parseSettings(this.credentialsFilename, false);
this.storeSettings(settings);
this.storeSettings(credentials);
// Init logging config
this.logconfig = this.defaultLogConfig(this.loglevel ? this.loglevel : this.defaultLogLevel);
this.initLogging(this.logconfig);
if (!this.skinName) {
this.logger.warn('No "skinName" parameter found. Please check out settings.json.template and ' +
'update your settings.json. Falling back to the default "colibris".');
this.skinName = 'colibris';
}
if (!this.socketTransportProtocols.includes("websocket") || this.socketTransportProtocols.includes("polling")) {
this.logger.warn("Invalid socketTransportProtocols setting. Please check out settings.json.template and update your settings.json. Falling back to the default ['websocket', 'polling'].");
this.socketTransportProtocols = ['websocket', 'polling'];
} }
// checks if skinName has an acceptable value, otherwise falls back to "colibris" // checks if skinName has an acceptable value, otherwise falls back to "colibris"
if (exports.skinName) { if (this.skinName) {
const skinBasePath = path.join(exports.root, 'src', 'static', 'skins'); const skinBasePath = path.join(this.root, 'src', 'static', 'skins');
const countPieces = exports.skinName.split(path.sep).length; const countPieces = this.skinName.split(path.sep).length;
if (countPieces !== 1) { if (countPieces !== 1) {
logger.error(`skinName must be the name of a directory under "${skinBasePath}". This is ` + this.logger.error(`skinName must be the name of a directory under "${skinBasePath}". This is ` +
`not valid: "${exports.skinName}". Falling back to the default "colibris".`); `not valid: "${this.skinName}". Falling back to the default "colibris".`);
this.skinName = 'colibris';
exports.skinName = 'colibris';
} }
// informative variable, just for the log messages // informative variable, just for the log messages
let skinPath = path.join(skinBasePath, exports.skinName); let skinPath = path.join(skinBasePath, this.skinName);
// what if someone sets skinName == ".." or "."? We catch him! // what if someone sets skinName == ".." or "."? We catch him!
if (absolutePaths.isSubdir(skinBasePath, skinPath) === false) { if (isSubdir(skinBasePath, skinPath) === false) {
logger.error(`Skin path ${skinPath} must be a subdirectory of ${skinBasePath}. ` + this.logger.error(`Skin path ${skinPath} must be a subdirectory of ${skinBasePath}. ` +
'Falling back to the default "colibris".'); 'Falling back to the default "colibris".');
exports.skinName = 'colibris'; this.skinName = 'colibris';
skinPath = path.join(skinBasePath, exports.skinName); skinPath = path.join(skinBasePath, this.skinName);
} }
if (fs.existsSync(skinPath) === false) { if (fs.existsSync(skinPath) === false) {
logger.error(`Skin path ${skinPath} does not exist. Falling back to the default "colibris".`); this.logger.error(`Skin path ${skinPath} does not exist. Falling back to the default "colibris".`);
exports.skinName = 'colibris'; this.skinName = 'colibris';
skinPath = path.join(skinBasePath, exports.skinName); skinPath = path.join(skinBasePath,this.skinName);
} }
logger.info(`Using skin "${exports.skinName}" in dir: ${skinPath}`); this.logger.info(`Using skin "${this.skinName}" in dir: ${skinPath}`);
} }
if (exports.abiword) { if (this.abiword) {
// Check abiword actually exists // Check abiword actually exists
if (exports.abiword != null) { if (this.abiword != null) {
fs.exists(exports.abiword, (exists: boolean) => { let exists = fs.existsSync(this.abiword)
if (!exists) { if (!exists) {
const abiwordError = 'Abiword does not exist at this path, check your settings file.'; const abiwordError = 'Abiword does not exist at this path, check your settings file.';
if (!exports.suppressErrorsInPadText) { if (!this.suppressErrorsInPadText) {
exports.defaultPadText += `\nError: ${abiwordError}${suppressDisableMsg}`; this.defaultPadText += `\nError: ${abiwordError}${this.suppressDisableMsg}`;
} }
logger.error(`${abiwordError} File location: ${exports.abiword}`); this.logger.error(`${abiwordError} File location: ${this.abiword}`);
exports.abiword = null; this.abiword = null;
} }
});
} }
} }
if (exports.soffice) { if (this.soffice) {
fs.exists(exports.soffice, (exists: boolean) => { let exists = fs.existsSync(this.soffice)
if (!exists) { if (!exists) {
const sofficeError = const sofficeError =
'soffice (libreoffice) does not exist at this path, check your settings file.'; 'soffice (libreoffice) does not exist at this path, check your settings file.';
if (!exports.suppressErrorsInPadText) { if (!this.suppressErrorsInPadText) {
exports.defaultPadText += `\nError: ${sofficeError}${suppressDisableMsg}`; this.defaultPadText += `\nError: ${sofficeError}${this.suppressDisableMsg}`;
} }
logger.error(`${sofficeError} File location: ${exports.soffice}`); this.logger.error(`${sofficeError} File location: ${this.soffice}`);
exports.soffice = null; this.soffice = null;
} }
});
} }
const sessionkeyFilename = absolutePaths.makeAbsolute(argv.sessionkey || './SESSIONKEY.txt'); const sessionkeyFilename = makeAbsolute(argvP.sessionkey || './SESSIONKEY.txt');
if (!exports.sessionKey) { if (!this.sessionKey) {
try { try {
exports.sessionKey = fs.readFileSync(sessionkeyFilename, 'utf8'); this.sessionKey = fs.readFileSync(sessionkeyFilename, 'utf8');
logger.info(`Session key loaded from: ${sessionkeyFilename}`); this.logger.info(`Session key loaded from: ${sessionkeyFilename}`);
} catch (err) { /* ignored */ } catch (err) { /* ignored */
} }
const keyRotationEnabled = exports.cookie.keyRotationInterval && exports.cookie.sessionLifetime; const keyRotationEnabled = this.cookie.keyRotationInterval && this.cookie.sessionLifetime;
if (!exports.sessionKey && !keyRotationEnabled) { if (!this.sessionKey && !keyRotationEnabled) {
logger.info( this.logger.info(
`Session key file "${sessionkeyFilename}" not found. Creating with random contents.`); `Session key file "${sessionkeyFilename}" not found. Creating with random contents.`);
exports.sessionKey = randomString(32); this.sessionKey = randomString(32);
fs.writeFileSync(sessionkeyFilename, exports.sessionKey, 'utf8'); fs.writeFileSync(sessionkeyFilename, this.sessionKey, 'utf8');
} }
} else { } else {
logger.warn('Declaring the sessionKey in the settings.json is deprecated. ' + this.logger.warn('Declaring the sessionKey in the settings.json is deprecated. ' +
'This value is auto-generated now. Please remove the setting from the file. -- ' + 'This value is auto-generated now. Please remove the setting from the file. -- ' +
'If you are seeing this error after restarting using the Admin User ' + 'If you are seeing this error after restarting using the Admin User ' +
'Interface then you can ignore this message.'); 'Interface then you can ignore this message.');
} }
if (exports.sessionKey) { if (this.sessionKey) {
logger.warn(`The sessionKey setting and ${sessionkeyFilename} file are deprecated; ` + this.logger.warn(`The sessionKey setting and ${sessionkeyFilename} file are deprecated; ` +
'use automatic key rotation instead (see the cookie.keyRotationInterval setting).'); 'use automatic key rotation instead (see the cookie.keyRotationInterval setting).');
} }
if (exports.dbType === 'dirty') { if (this.dbType === 'dirty') {
const dirtyWarning = 'DirtyDB is used. This is not recommended for production.'; const dirtyWarning = 'DirtyDB is used. This is not recommended for production.';
if (!exports.suppressErrorsInPadText) { if (!this.suppressErrorsInPadText) {
exports.defaultPadText += `\nWarning: ${dirtyWarning}${suppressDisableMsg}`; this.defaultPadText += `\nWarning: ${dirtyWarning}${this.suppressDisableMsg}`;
} }
exports.dbSettings.filename = absolutePaths.makeAbsolute(exports.dbSettings.filename); this.dbSettings.filename = makeAbsolute(this.dbSettings.filename);
logger.warn(`${dirtyWarning} File location: ${exports.dbSettings.filename}`); this.logger.warn(`${dirtyWarning} File location: ${this.dbSettings.filename}`);
} }
if (exports.ip === '') { if (this.ip === '') {
// using Unix socket for connectivity // using Unix socket for connectivity
logger.warn('The settings file contains an empty string ("") for the "ip" parameter. The ' + this.logger.warn('The settings file contains an empty string ("") for the "ip" parameter. The ' +
'"port" parameter will be interpreted as the path to a Unix socket to bind at.'); '"port" parameter will be interpreted as the path to a Unix socket to bind at.');
} }
@ -947,13 +950,14 @@ exports.reloadSettings = () => {
* ACHTUNG: this may prevent caching HTTP proxies to work * ACHTUNG: this may prevent caching HTTP proxies to work
* TODO: remove the "?v=randomstring" parameter, and replace with hashed filenames instead * TODO: remove the "?v=randomstring" parameter, and replace with hashed filenames instead
*/ */
exports.randomVersionString = randomString(4); this.randomVersionString = randomString(4);
logger.info(`Random string used for versioning assets: ${exports.randomVersionString}`); this.logger.info(`Random string used for versioning assets: ${this.randomVersionString}`);
}; };
}
const settings = new Settings()
export default settings
exports.exportedForTestingOnly = {
parseSettings,
};
// initially load settings
exports.reloadSettings();

View file

@ -5,12 +5,12 @@
* objects lack. * objects lack.
*/ */
class Stream { class Stream {
private _iter private readonly _iter
private _next: any private _next: any
/** /**
* @returns {Stream} A Stream that yields values in the half-open range [start, end). * @returns {Stream} A Stream that yields values in the half-open range [start, end).
*/ */
static range(start: number, end: number) { static range(start: number, end: number): Stream {
return new Stream((function* () { for (let i = start; i < end; ++i) yield i; })()); return new Stream((function* () { for (let i = start; i < end; ++i) yield i; })());
} }
@ -127,7 +127,7 @@ class Stream {
* @param {(v: any) => any} fn - Value transformation function. * @param {(v: any) => any} fn - Value transformation function.
* @returns {Stream} A new Stream that yields this Stream's values, transformed by `fn`. * @returns {Stream} A new Stream that yields this Stream's values, transformed by `fn`.
*/ */
map(fn:Function) { return new Stream((function* () { // @ts-ignore map(fn:Function): Stream { return new Stream((function* () { // @ts-ignore
for (const v of this) yield fn(v); }).call(this)); } for (const v of this) yield fn(v); }).call(this)); }
/** /**
@ -136,4 +136,4 @@ class Stream {
[Symbol.iterator]() { return this._iter; } [Symbol.iterator]() { return this._iter; }
} }
module.exports = Stream; export default Stream;

View file

@ -1,9 +1,11 @@
'use strict'; 'use strict';
const semver = require('semver'); import semver from 'semver';
const settings = require('./Settings'); import {getEpVersion} from './Settings';
import axios from 'axios'; import axios from 'axios';
const headers = { const headers = {
'User-Agent': 'Etherpad/' + settings.getEpVersion(), 'User-Agent': 'Etherpad/' + getEpVersion(),
} }
type Infos = { type Infos = {
@ -37,15 +39,15 @@ const loadEtherpadInformations = () => {
} }
exports.getLatestVersion = () => { export const getLatestVersion = () => {
exports.needsUpdate().catch(); needsUpdate().catch();
return infos?.latestVersion; return infos?.latestVersion;
}; };
exports.needsUpdate = async (cb?: Function) => { export const needsUpdate = async (cb?: Function) => {
try { try {
const info = await loadEtherpadInformations() const info = await loadEtherpadInformations()
if (semver.gt(info!.latestVersion, settings.getEpVersion())) { if (semver.gt(info!.latestVersion, getEpVersion())) {
if (cb) return cb(true); if (cb) return cb(true);
} }
} catch (err) { } catch (err) {
@ -54,8 +56,8 @@ exports.needsUpdate = async (cb?: Function) => {
} }
}; };
exports.check = () => { export const check = () => {
exports.needsUpdate((needsUpdate: boolean) => { needsUpdate((needsUpdate: boolean) => {
if (needsUpdate) { if (needsUpdate) {
console.warn(`Update available: Download the actual version ${infos.latestVersion}`); console.warn(`Update available: Download the actual version ${infos.latestVersion}`);
} }

View file

@ -22,7 +22,7 @@ const fsp = fs.promises;
import path from 'path'; import path from 'path';
import zlib from 'zlib'; import zlib from 'zlib';
const settings = require('./Settings'); const settings = require('./Settings');
const existsSync = require('./path_exists'); import existsSync from './path_exists';
import util from 'util'; import util from 'util';
/* /*

View file

@ -1,10 +1,10 @@
'use strict'; 'use strict';
const CustomError = require('../utils/customError'); import CustomError from '../utils/customError';
// checks if a rev is a legal number // checks if a rev is a legal number
// pre-condition is that `rev` is not undefined // pre-condition is that `rev` is not undefined
const checkValidRev = (rev: number|string) => { export const checkValidRev = (rev: number|string) => {
if (typeof rev !== 'number') { if (typeof rev !== 'number') {
rev = parseInt(rev, 10); rev = parseInt(rev, 10);
} }
@ -28,7 +28,4 @@ const checkValidRev = (rev: number|string) => {
}; };
// checks if a number is an int // checks if a number is an int
const isInt = (value:number) => (parseFloat(String(value)) === parseInt(String(value), 10)) && !isNaN(value); export const isInt = (value:number) => (parseFloat(String(value)) === parseInt(String(value), 10)) && !isNaN(value);
exports.isInt = isInt;
exports.checkValidRev = checkValidRev;

View file

@ -21,4 +21,4 @@ class CustomError extends Error {
} }
} }
module.exports = CustomError; export default CustomError

View file

@ -4,19 +4,25 @@ import {PadAuthor, PadType} from "../types/PadType";
import {MapArrayType} from "../types/MapType"; import {MapArrayType} from "../types/MapType";
import AttributeMap from '../../static/js/AttributeMap'; import AttributeMap from '../../static/js/AttributeMap';
const Changeset = require('../../static/js/Changeset'); import {applyToAText, checkRep, compose, deserializeOps, pack, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset';
const attributes = require('../../static/js/attributes'); import {attribsFromString} from '../../static/js/attributes';
const exportHtml = require('./ExportHtml'); import {getHTMLFromAtext} from './ExportHtml';
import {Builder} from "../../static/js/Builder";
import Pad from "../db/Pad";
import {OpAssembler} from "../../static/js/OpAssembler";
import {numToString} from "../../static/js/ChangesetUtils";
import Op from "../../static/js/Op";
import {StringAssembler} from "../../static/js/StringAssembler";
class PadDiff { class PadDiff {
private readonly _pad: PadType; private readonly _pad: Pad;
private readonly _fromRev: string; private readonly _fromRev: string;
private readonly _toRev: string; private readonly _toRev: string;
private _html: any; private _html: any;
public _authors: any[]; public _authors: any[];
private self: PadDiff | undefined private self: PadDiff | undefined
constructor(pad: PadType, fromRev:string, toRev:string) { constructor(pad: Pad, fromRev:number, toRev:number) {
// check parameters // check parameters
if (!pad || !pad.id || !pad.atext || !pad.pool) { if (!pad || !pad.id || !pad.atext || !pad.pool) {
throw new Error('Invalid pad'); throw new Error('Invalid pad');
@ -33,7 +39,7 @@ class PadDiff {
} }
_isClearAuthorship(changeset: any){ _isClearAuthorship(changeset: any){
// unpack // unpack
const unpacked = Changeset.unpack(changeset); const unpacked = unpack(changeset);
// check if there is nothing in the charBank // check if there is nothing in the charBank
if (unpacked.charBank !== '') { if (unpacked.charBank !== '') {
@ -45,7 +51,7 @@ class PadDiff {
return false; return false;
} }
const [clearOperator, anotherOp] = Changeset.deserializeOps(unpacked.ops); const [clearOperator, anotherOp] = deserializeOps(unpacked.ops);
// check if there is only one operator // check if there is only one operator
if (anotherOp != null) return false; if (anotherOp != null) return false;
@ -62,7 +68,7 @@ class PadDiff {
} }
const [appliedAttribute, anotherAttribute] = const [appliedAttribute, anotherAttribute] =
attributes.attribsFromString(clearOperator.attribs, this._pad.pool); attribsFromString(clearOperator.attribs, this._pad.pool);
// Check that the operation has exactly one attribute. // Check that the operation has exactly one attribute.
if (appliedAttribute == null || anotherAttribute != null) return false; if (appliedAttribute == null || anotherAttribute != null) return false;
@ -78,7 +84,7 @@ class PadDiff {
const atext = await this._pad.getInternalRevisionAText(rev); const atext = await this._pad.getInternalRevisionAText(rev);
// build clearAuthorship changeset // build clearAuthorship changeset
const builder = Changeset.builder(atext.text.length); const builder = new Builder(atext.text.length);
builder.keepText(atext.text, [['author', '']], this._pad.pool); builder.keepText(atext.text, [['author', '']], this._pad.pool);
const changeset = builder.toString(); const changeset = builder.toString();
@ -93,7 +99,7 @@ class PadDiff {
const changeset = await this._createClearAuthorship(rev); const changeset = await this._createClearAuthorship(rev);
// apply the clearAuthorship changeset // apply the clearAuthorship changeset
const newAText = Changeset.applyToAText(changeset, atext, this._pad.pool); const newAText = applyToAText(changeset, atext, this._pad.pool);
return newAText; return newAText;
} }
@ -157,7 +163,7 @@ class PadDiff {
if (superChangeset == null) { if (superChangeset == null) {
superChangeset = changeset; superChangeset = changeset;
} else { } else {
superChangeset = Changeset.compose(superChangeset, changeset, this._pad.pool); superChangeset = compose(superChangeset, changeset, this._pad.pool);
} }
} }
@ -171,10 +177,10 @@ class PadDiff {
const deletionChangeset = this._createDeletionChangeset(superChangeset, atext, this._pad.pool); const deletionChangeset = this._createDeletionChangeset(superChangeset, atext, this._pad.pool);
// apply the superChangeset, which includes all addings // apply the superChangeset, which includes all addings
atext = Changeset.applyToAText(superChangeset, atext, this._pad.pool); atext = applyToAText(superChangeset, atext, this._pad.pool);
// apply the deletionChangeset, which adds a deletions // apply the deletionChangeset, which adds a deletions
atext = Changeset.applyToAText(deletionChangeset, atext, this._pad.pool); atext = applyToAText(deletionChangeset, atext, this._pad.pool);
} }
return atext; return atext;
@ -192,7 +198,8 @@ class PadDiff {
const authorColors = await this._pad.getAllAuthorColors(); const authorColors = await this._pad.getAllAuthorColors();
// convert the atext to html // convert the atext to html
this._html = await exportHtml.getHTMLFromAtext(this._pad, atext, authorColors); // @ts-ignore
this._html = await getHTMLFromAtext(this._pad, atext, authorColors);
return this._html; return this._html;
} }
@ -209,22 +216,22 @@ class PadDiff {
_extendChangesetWithAuthor(changeset: any, author: any, apool: any){ _extendChangesetWithAuthor(changeset: any, author: any, apool: any){
// unpack // unpack
const unpacked = Changeset.unpack(changeset); const unpacked = unpack(changeset);
const assem = Changeset.opAssembler(); const assem = new OpAssembler();
// create deleted attribs // create deleted attribs
const authorAttrib = apool.putAttrib(['author', author || '']); const authorAttrib = apool.putAttrib(['author', author || '']);
const deletedAttrib = apool.putAttrib(['removed', true]); const deletedAttrib = apool.putAttrib(['removed', true]);
const attribs = `*${Changeset.numToString(authorAttrib)}*${Changeset.numToString(deletedAttrib)}`; const attribs = `*${numToString(authorAttrib)}*${numToString(deletedAttrib)}`;
for (const operator of Changeset.deserializeOps(unpacked.ops)) { for (const operator of deserializeOps(unpacked.ops)) {
if (operator.opcode === '-') { if (operator.opcode === '-') {
// this is a delete operator, extend it with the author // this is a delete operator, extend it with the author
operator.attribs = attribs; operator.attribs = attribs;
} else if (operator.opcode === '=' && operator.attribs) { } else if (operator.opcode === '=' && operator.attribs) {
// this is operator changes only attributes, let's mark which author did that // this is operator changes only attributes, let's mark which author did that
operator.attribs += `*${Changeset.numToString(authorAttrib)}`; operator.attribs += `*${numToString(authorAttrib)}`;
} }
// append the new operator to our assembler // append the new operator to our assembler
@ -232,18 +239,19 @@ class PadDiff {
} }
// return the modified changeset // return the modified changeset
return Changeset.pack(unpacked.oldLen, unpacked.newLen, assem.toString(), unpacked.charBank); return pack(unpacked.oldLen, unpacked.newLen, assem.toString(), unpacked.charBank);
} }
_createDeletionChangeset(cs: any, startAText: any, apool: any){ _createDeletionChangeset(cs: any, startAText: any, apool: any){
const lines = Changeset.splitTextLines(startAText.text); const lines = splitTextLines(startAText.text)!;
const alines = Changeset.splitAttributionLines(startAText.attribs, startAText.text); const alines = splitAttributionLines(startAText.attribs, startAText.text);
// lines and alines are what the exports is meant to apply to. // lines and alines are what the exports is meant to apply to.
// They may be arrays or objects with .get(i) and .length methods. // They may be arrays or objects with .get(i) and .length methods.
// They include final newlines on lines. // They include final newlines on lines.
const linesGet = (idx: number) => { const linesGet = (idx: number) => {
if (lines.get) { if ("get" in lines) {
// @ts-ignore
return lines.get(idx); return lines.get(idx);
} else { } else {
return lines[idx]; return lines[idx];
@ -251,7 +259,8 @@ class PadDiff {
}; };
const aLinesGet = (idx: number) => { const aLinesGet = (idx: number) => {
if (alines.get) { if ("get" in alines) {
// @ts-ignore
return alines.get(idx); return alines.get(idx);
} else { } else {
return alines[idx]; return alines[idx];
@ -263,14 +272,14 @@ class PadDiff {
let curLineOps: { next: () => any; } | null = null; let curLineOps: { next: () => any; } | null = null;
let curLineOpsNext: { done: any; value: any; } | null = null; let curLineOpsNext: { done: any; value: any; } | null = null;
let curLineOpsLine: number; let curLineOpsLine: number;
let curLineNextOp = new Changeset.Op('+'); let curLineNextOp = new Op('+');
const unpacked = Changeset.unpack(cs); const unpacked = unpack(cs);
const builder = Changeset.builder(unpacked.newLen); const builder = new Builder(unpacked.newLen);
const consumeAttribRuns = (numChars: number, func: Function /* (len, attribs, endsLine)*/) => { const consumeAttribRuns = (numChars: number, func: Function /* (len, attribs, endsLine)*/) => {
if (!curLineOps || curLineOpsLine !== curLine) { if (!curLineOps || curLineOpsLine !== curLine) {
curLineOps = Changeset.deserializeOps(aLinesGet(curLine)); curLineOps = deserializeOps(aLinesGet(curLine));
curLineOpsNext = curLineOps!.next(); curLineOpsNext = curLineOps!.next();
curLineOpsLine = curLine; curLineOpsLine = curLine;
let indexIntoLine = 0; let indexIntoLine = 0;
@ -291,13 +300,13 @@ class PadDiff {
curChar = 0; curChar = 0;
curLineOpsLine = curLine; curLineOpsLine = curLine;
curLineNextOp.chars = 0; curLineNextOp.chars = 0;
curLineOps = Changeset.deserializeOps(aLinesGet(curLine)); curLineOps = deserializeOps(aLinesGet(curLine));
curLineOpsNext = curLineOps!.next(); curLineOpsNext = curLineOps!.next();
} }
if (!curLineNextOp.chars) { if (!curLineNextOp.chars) {
if (curLineOpsNext!.done) { if (curLineOpsNext!.done) {
curLineNextOp = new Changeset.Op(); curLineNextOp = new Op();
} else { } else {
curLineNextOp = curLineOpsNext!.value; curLineNextOp = curLineOpsNext!.value;
curLineOpsNext = curLineOps!.next(); curLineOpsNext = curLineOps!.next();
@ -332,7 +341,7 @@ class PadDiff {
const nextText = (numChars: number) => { const nextText = (numChars: number) => {
let len = 0; let len = 0;
const assem = Changeset.stringAssembler(); const assem = new StringAssembler();
const firstString = linesGet(curLine).substring(curChar); const firstString = linesGet(curLine).substring(curChar);
len += firstString.length; len += firstString.length;
assem.append(firstString); assem.append(firstString);
@ -360,7 +369,7 @@ class PadDiff {
}; };
}; };
for (const csOp of Changeset.deserializeOps(unpacked.ops)) { for (const csOp of deserializeOps(unpacked.ops)) {
if (csOp.opcode === '=') { if (csOp.opcode === '=') {
const textBank = nextText(csOp.chars); const textBank = nextText(csOp.chars);
@ -442,7 +451,7 @@ class PadDiff {
} }
} }
return Changeset.checkRep(builder.toString()); return checkRep(builder.toString());
} }
} }
@ -450,9 +459,9 @@ class PadDiff {
// this method is 80% like Changeset.inverse. I just changed so instead of reverting, // this method is 80% like Changeset.inverse. I just changed so instead of reverting,
// it adds deletions and attribute changes to the atext. // it adds deletions and attribute changes to the atext.
// @ts-ignore
PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) { PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) {
}; };
// export the constructor export default PadDiff
module.exports = PadDiff;

View file

@ -1,5 +1,5 @@
'use strict'; 'use strict';
const fs = require('fs'); import fs from 'fs';
const check = (path:string) => { const check = (path:string) => {
const existsSync = fs.statSync || fs.existsSync; const existsSync = fs.statSync || fs.existsSync;
@ -13,4 +13,4 @@ const check = (path:string) => {
return result; return result;
}; };
module.exports = check; export default check

View file

@ -7,7 +7,7 @@
// `predicate`. Resolves to `undefined` if none of the Promises satisfy `predicate`, or if // `predicate`. Resolves to `undefined` if none of the Promises satisfy `predicate`, or if
// `promises` is empty. If `predicate` is nullish, the truthiness of the resolved value is used as // `promises` is empty. If `predicate` is nullish, the truthiness of the resolved value is used as
// the predicate. // the predicate.
exports.firstSatisfies = <T>(promises: Promise<T>[], predicate: null|Function) => { export const firstSatisfies = <T>(promises: Promise<T>[], predicate: null|Function) => {
if (predicate == null) { if (predicate == null) {
predicate = (x: any) => x; predicate = (x: any) => x;
} }
@ -44,7 +44,7 @@ exports.firstSatisfies = <T>(promises: Promise<T>[], predicate: null|Function) =
// `total` is greater than `concurrency`, then `concurrency` Promises will be created right away, // `total` is greater than `concurrency`, then `concurrency` Promises will be created right away,
// and each remaining Promise will be created once one of the earlier Promises resolves.) This async // and each remaining Promise will be created once one of the earlier Promises resolves.) This async
// function resolves once all `total` Promises have resolved. // function resolves once all `total` Promises have resolved.
exports.timesLimit = async (total: number, concurrency: number, promiseCreator: Function) => { export const timesLimit = async (total: number, concurrency: number, promiseCreator: Function) => {
if (total > 0 && concurrency <= 0) throw new RangeError('concurrency must be positive'); if (total > 0 && concurrency <= 0) throw new RangeError('concurrency must be positive');
let next = 0; let next = 0;
const addAnother = () => promiseCreator(next++).finally(() => { const addAnother = () => promiseCreator(next++).finally(() => {
@ -61,7 +61,9 @@ exports.timesLimit = async (total: number, concurrency: number, promiseCreator:
* An ordinary Promise except the `resolve` and `reject` executor functions are exposed as * An ordinary Promise except the `resolve` and `reject` executor functions are exposed as
* properties. * properties.
*/ */
class Gate<T> extends Promise<T> { export class Gate<T> extends Promise<T> {
resolve?:Function
private reject?:Function
// Coax `.then()` into returning an ordinary Promise, not a Gate. See // Coax `.then()` into returning an ordinary Promise, not a Gate. See
// https://stackoverflow.com/a/65669070 for the rationale. // https://stackoverflow.com/a/65669070 for the rationale.
static get [Symbol.species]() { return Promise; } static get [Symbol.species]() { return Promise; }
@ -75,4 +77,3 @@ class Gate<T> extends Promise<T> {
Object.assign(this, props); Object.assign(this, props);
} }
} }
exports.Gate = Gate;

View file

@ -3,8 +3,6 @@
* Generates a random String with the given length. Is needed to generate the * Generates a random String with the given length. Is needed to generate the
* Author, Group, readonly, session Ids * Author, Group, readonly, session Ids
*/ */
const cryptoMod = require('crypto'); import {randomBytes} from 'crypto';
const randomString = (len: number) => cryptoMod.randomBytes(len).toString('hex'); export const randomString = (len: number) => randomBytes(len).toString('hex');
module.exports = randomString;

View file

@ -1,13 +1,13 @@
'use strict'; 'use strict';
import {ErrorExtended, RunCMDOptions, RunCMDPromise} from "../types/RunCMDOptions"; import {ErrorExtended, RunCMDOptions, RunCMDPromise} from "../types/RunCMDOptions";
import {ChildProcess} from "node:child_process"; import {ChildProcess, SpawnOptions} from "node:child_process";
import {PromiseWithStd} from "../types/PromiseWithStd"; import {PromiseWithStd} from "../types/PromiseWithStd";
import {Readable} from "node:stream"; import {Readable} from "node:stream";
const spawn = require('cross-spawn'); import spawn from 'cross-spawn';
const log4js = require('log4js'); import log4js from 'log4js';
const path = require('path'); import path from 'path';
const settings = require('./Settings'); const settings = require('./Settings');
const logger = log4js.getLogger('runCmd'); const logger = log4js.getLogger('runCmd');
@ -74,7 +74,7 @@ const logLines = (readable: undefined | Readable | null, logLineFn: (arg0: (stri
* - `stderr`: Similar to `stdout` but for stderr. * - `stderr`: Similar to `stdout` but for stderr.
* - `child`: The ChildProcess object. * - `child`: The ChildProcess object.
*/ */
module.exports = exports = (args: string[], opts:RunCMDOptions = {}) => { export default (args: string[], opts:RunCMDOptions = {}) => {
logger.debug(`Executing command: ${args.join(' ')}`); logger.debug(`Executing command: ${args.join(' ')}`);
opts = {cwd: settings.root, ...opts}; opts = {cwd: settings.root, ...opts};
@ -123,7 +123,7 @@ module.exports = exports = (args: string[], opts:RunCMDOptions = {}) => {
// process's `exit` handler so that we get a useful stack trace. // process's `exit` handler so that we get a useful stack trace.
const procFailedErr: Error & ErrorExtended = new Error(); const procFailedErr: Error & ErrorExtended = new Error();
const proc: ChildProcess = spawn(args[0], args.slice(1), opts); const proc: ChildProcess = spawn(args[0], args.slice(1), opts as SpawnOptions);
const streams:[undefined, Readable|null, Readable|null] = [undefined, proc.stdout, proc.stderr]; const streams:[undefined, Readable|null, Readable|null] = [undefined, proc.stdout, proc.stderr];
let px: { reject: any; resolve: any; }; let px: { reject: any; resolve: any; };

View file

@ -1,10 +1,10 @@
'use strict'; 'use strict';
const path = require('path'); import path from 'path';
// Normalizes p and ensures that it is a relative path that does not reach outside. See // Normalizes p and ensures that it is a relative path that does not reach outside. See
// https://nvd.nist.gov/vuln/detail/CVE-2015-3297 for additional context. // https://nvd.nist.gov/vuln/detail/CVE-2015-3297 for additional context.
module.exports = (p: string, pathApi = path) => { export default (p: string, pathApi = path) => {
// The documentation for path.normalize() says that it resolves '..' and '.' segments. The word // The documentation for path.normalize() says that it resolves '..' and '.' segments. The word
// "resolve" implies that it examines the filesystem to resolve symbolic links, so 'a/../b' might // "resolve" implies that it examines the filesystem to resolve symbolic links, so 'a/../b' might
// not be the same thing as 'b'. Most path normalization functions from other libraries (e.g., // not be the same thing as 'b'. Most path normalization functions from other libraries (e.g.,

View file

@ -50,7 +50,7 @@ type ButtonGroupType = {
} }
class ButtonGroup { class ButtonGroup {
private buttons: Button[] private readonly buttons: Button[]
constructor() { constructor() {
this.buttons = [] this.buttons = []
@ -99,7 +99,8 @@ class Button {
} }
public static load(btnName: string) { public static load(btnName: string) {
const button = module.exports.availableButtons[btnName]; // @ts-ignore
const button = toolbar.availableButtons[btnName];
try { try {
if (button.constructor === Button || button.constructor === SelectButton) { if (button.constructor === Button || button.constructor === SelectButton) {
return button; return button;
@ -189,7 +190,7 @@ class Separator {
} }
} }
module.exports = { const toolbar = {
availableButtons: { availableButtons: {
bold: defaultButtonAttributes('bold'), bold: defaultButtonAttributes('bold'),
italic: defaultButtonAttributes('italic'), italic: defaultButtonAttributes('italic'),
@ -261,6 +262,7 @@ module.exports = {
}, },
registerButton(buttonName: string, buttonInfo: any) { registerButton(buttonName: string, buttonInfo: any) {
// @ts-ignore
this.availableButtons[buttonName] = buttonInfo; this.availableButtons[buttonName] = buttonInfo;
}, },
@ -304,3 +306,5 @@ module.exports = {
return groups.join(this.separator()); return groups.join(this.separator());
}, },
}; };
export default toolbar

View file

@ -31,6 +31,7 @@
], ],
"dependencies": { "dependencies": {
"@etherpad/express-session": "^1.18.2", "@etherpad/express-session": "^1.18.2",
"@types/cookie-parser": "^1.4.7",
"async": "^3.2.5", "async": "^3.2.5",
"axios": "^1.7.2", "axios": "^1.7.2",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
@ -83,19 +84,24 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.45.2", "@playwright/test": "^1.45.2",
"@types/async": "^3.2.24", "@types/async": "^3.2.24",
"@types/cross-spawn": "^6.0.6",
"@types/ejs": "^3.1.5",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/formidable": "^3.4.5", "@types/formidable": "^3.4.5",
"@types/http-errors": "^2.0.4", "@types/http-errors": "^2.0.4",
"@types/jquery": "^3.5.30", "@types/jquery": "^3.5.30",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/jsonminify": "^0.4.3",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"@types/mocha": "^10.0.7", "@types/mocha": "^10.0.7",
"@types/node": "^20.14.11", "@types/node": "^20.14.11",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.1",
"@types/resolve": "^1.20.6",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/sinon": "^17.0.3", "@types/sinon": "^17.0.3",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/tinycon": "^0.6.5",
"@types/underscore": "^1.11.15", "@types/underscore": "^1.11.15",
"@types/unorm": "^1.3.31", "@types/unorm": "^1.3.31",
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
@ -110,7 +116,8 @@
"sinon": "^18.0.0", "sinon": "^18.0.0",
"split-grid": "^1.0.11", "split-grid": "^1.0.11",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"typescript": "^5.5.3" "typescript": "^5.5.3",
"vitest": "^2.0.3"
}, },
"engines": { "engines": {
"node": ">=18.18.2", "node": ">=18.18.2",

View file

@ -62,7 +62,7 @@ export class Builder {
* attribute key, value pairs. * attribute key, value pairs.
* @returns {Builder} this * @returns {Builder} this
*/ */
keepText= (text: string, attribs: string|Attribute[], pool?: AttributePool): Builder=> { keepText= (text: string, attribs?: string|Attribute[], pool?: AttributePool): Builder=> {
for (const op of opsFromText('=', text, attribs, pool)) this.assem.append(op); for (const op of opsFromText('=', text, attribs, pool)) this.assem.append(op);
return this; return this;
} }
@ -89,7 +89,7 @@ export class Builder {
* character must be a newline. * character must be a newline.
* @returns {Builder} this * @returns {Builder} this
*/ */
remove= (N: number, L: number): Builder => { remove= (N: number, L?: number): Builder => {
this.o.opcode = '-'; this.o.opcode = '-';
this.o.attribs = ''; this.o.attribs = '';
this.o.chars = N; this.o.chars = N;

View file

@ -445,10 +445,10 @@ export const applyToText = (cs: string, str: string): string => {
* @param {string} cs - the changeset to apply * @param {string} cs - the changeset to apply
* @param {string[]} lines - The lines to which the changeset needs to be applied * @param {string[]} lines - The lines to which the changeset needs to be applied
*/ */
const mutateTextLines = (cs: string, lines:string[]) => { export const mutateTextLines = (cs: string, lines: RegExpMatchArray | null) => {
const unpacked = unpack(cs); const unpacked = unpack(cs);
const bankIter = new StringIterator(unpacked.charBank); const bankIter = new StringIterator(unpacked.charBank);
const mut = new TextLinesMutator(lines); const mut = new TextLinesMutator(lines!);
for (const op of deserializeOps(unpacked.ops)) { for (const op of deserializeOps(unpacked.ops)) {
switch (op.opcode) { switch (op.opcode) {
case '+': case '+':
@ -709,7 +709,7 @@ export const identity = (N: number): string => pack(N, N, '', '');
* @param {AttributePool.ts} [pool] - Attribute pool. * @param {AttributePool.ts} [pool] - Attribute pool.
* @returns {string} * @returns {string}
*/ */
export const makeSplice = (orig: string, start: number, ndel: number, ins: string, attribs: string | Attribute[] | undefined, pool: AttributePool | null | undefined): string => { export const makeSplice = (orig: string, start: number, ndel: number, ins: string|null, attribs?: string | Attribute[] | undefined, pool?: AttributePool | null | undefined): string => {
if (start < 0) throw new RangeError(`start index must be non-negative (is ${start})`); 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})`); if (ndel < 0) throw new RangeError(`characters to delete must be non-negative (is ${ndel})`);
if (start > orig.length) start = orig.length; if (start > orig.length) start = orig.length;
@ -723,7 +723,7 @@ export const makeSplice = (orig: string, start: number, ndel: number, ins: strin
})(); })();
for (const op of ops) assem.append(op); for (const op of ops) assem.append(op);
assem.endDocument(); assem.endDocument();
return pack(orig.length, orig.length + ins.length - ndel, assem.toString(), ins); return pack(orig.length, orig.length + ins!.length - ndel, assem.toString(), ins!);
}; };
/** /**
@ -932,7 +932,7 @@ export const mapAttribNumbers = (cs: string, func: Function): string => {
* attributes * attributes
* @returns {AText} * @returns {AText}
*/ */
export const makeAText = (text: string, attribs: string): AText => ({ export const makeAText = (text: string, attribs?: string): AText => ({
text, text,
attribs: (attribs || makeAttribution(text)), attribs: (attribs || makeAttribution(text)),
}); });
@ -1119,7 +1119,7 @@ export const makeAttribsString = (opcode: string, attribs: Attribute[]|string, p
/** /**
* Like "substring" but on a single-line attribution string. * Like "substring" but on a single-line attribution string.
*/ */
export const subattribution = (astr: string, start: number, optEnd: number) => { export const subattribution = (astr: string, start: number, optEnd?: number) => {
const attOps = deserializeOps(astr); const attOps = deserializeOps(astr);
let attOpsNext = attOps.next(); let attOpsNext = attOps.next();
const assem = new SmartOpAssembler(); const assem = new SmartOpAssembler();
@ -1164,9 +1164,7 @@ export const subattribution = (astr: string, start: number, optEnd: number) => {
return assem.toString(); return assem.toString();
}; };
export const inverse = (cs: string, lines: string|{ export const inverse = (cs: string, lines: string|RegExpMatchArray | null, alines: string[]|{
get: (idx: number) => string,
}, alines: string|{
get: (idx: number) => string, get: (idx: number) => string,
}, pool: AttributePool) => { }, pool: AttributePool) => {
// lines and alines are what the exports is meant to apply to. // lines and alines are what the exports is meant to apply to.
@ -1176,9 +1174,10 @@ export const inverse = (cs: string, lines: string|{
const linesGet = (idx: number) => { const linesGet = (idx: number) => {
// @ts-ignore // @ts-ignore
if ("get" in lines) { if ("get" in lines) {
// @ts-ignore
return lines.get(idx); return lines.get(idx);
} else { } else {
return lines[idx]; return lines![idx];
} }
}; };

View file

@ -8,11 +8,11 @@ import {padUtils} from './pad_utils'
* *
* Supports serialization to JSON. * Supports serialization to JSON.
*/ */
class ChatMessage { export class ChatMessage {
customMetadata: any customMetadata: any
text: string|null text: string|null
public authorId: string|null public authorId: string|null
private displayName: string|null displayName: string|null
time: number|null time: number|null
static fromObject(obj: ChatMessage) { static fromObject(obj: ChatMessage) {
// The userId property was renamed to authorId, and userName was renamed to displayName. Accept // The userId property was renamed to authorId, and userName was renamed to displayName. Accept

View file

@ -19,7 +19,7 @@ export class TextLinesMutator {
/** /**
* @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place). * @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place).
*/ */
constructor(lines: string[]) { constructor(lines: string[]| RegExpMatchArray ) {
this.lines = lines; this.lines = lines;
/** /**
* this._curSplice holds values that will be passed as arguments to this._lines.splice() to * this._curSplice holds values that will be passed as arguments to this._lines.splice() to

View file

@ -25,13 +25,22 @@
// of the document. These revisions are connected together by various // of the document. These revisions are connected together by various
// changesets, or deltas, between any two revisions. // changesets, or deltas, between any two revisions.
const loadBroadcastRevisionsJS = () => { type RevisionDelta = {
function Revision(revNum) { deltaRev: number;
deltaTime: number;
getValue: () => RevisionDelta;
}
export class Revision {
rev: number;
changesets: RevisionDelta[];
constructor(revNum: number) {
this.rev = revNum; this.rev = revNum;
this.changesets = []; this.changesets = [];
} }
Revision.prototype.addChangeset = function (destIndex, changeset, timeDelta) { addChangeset(destIndex: number, changeset: RevisionDelta, timeDelta: number) {
const changesetWrapper = { const changesetWrapper = {
deltaRev: destIndex - this.rev, deltaRev: destIndex - this.rev,
deltaTime: timeDelta, deltaTime: timeDelta,
@ -39,34 +48,39 @@ const loadBroadcastRevisionsJS = () => {
}; };
this.changesets.push(changesetWrapper); this.changesets.push(changesetWrapper);
this.changesets.sort((a, b) => (b.deltaRev - a.deltaRev)); this.changesets.sort((a, b) => (b.deltaRev - a.deltaRev));
}; }
const revisionInfo = {};
revisionInfo.addChangeset = function (fromIndex, toIndex, changeset, backChangeset, timeDelta) {
const startRevision = this[fromIndex] || this.createNew(fromIndex);
const endRevision = this[toIndex] || this.createNew(toIndex);
startRevision.addChangeset(toIndex, changeset, timeDelta);
endRevision.addChangeset(fromIndex, backChangeset, -1 * timeDelta);
};
revisionInfo.latest = clientVars.collab_client_vars.rev || -1;
revisionInfo.createNew = function (index) {
this[index] = new Revision(index);
if (index > this.latest) {
this.latest = index;
} }
return this[index]; class RevisionInfo {
}; private revisionInfo: Record<number|string, number|Revision> = {};
constructor() {
this.revisionInfo.latest = window.clientVars.collab_client_vars.rev || -1;
window.revisionInfo = this.revisionInfo;
}
addChangeset = (fromIndex: number, toIndex: number, changeset: RevisionDelta, backChangeset: RevisionDelta, timeDelta: number)=> {
const startRevision = (this.revisionInfo[fromIndex] || this.createNew(fromIndex)) as Revision;
const endRevision = (this.revisionInfo[toIndex] || this.createNew(toIndex)) as Revision;
startRevision.addChangeset(toIndex, changeset, timeDelta);
endRevision.addChangeset(fromIndex, backChangeset, -1 * timeDelta);
}
createNew = (index: number)=> {
this.revisionInfo![index] = new Revision(index);
if (index > (this.revisionInfo.latest as number)) {
this.revisionInfo.latest = index;
}
return this.revisionInfo[index];
}
// assuming that there is a path from fromIndex to toIndex, and that the links // assuming that there is a path from fromIndex to toIndex, and that the links
// are laid out in a skip-list format // are laid out in a skip-list format
revisionInfo.getPath = function (fromIndex, toIndex) { getPath = (fromIndex: number, toIndex: number)=> {
const changesets = []; const changesets = [];
const spans = []; const spans = [];
const times = []; const times = [];
let elem = this[fromIndex] || this.createNew(fromIndex); let elem = (this.revisionInfo[fromIndex] || this.createNew(fromIndex)) as Revision;
if (elem.changesets.length !== 0 && fromIndex !== toIndex) { if (elem.changesets.length !== 0 && fromIndex !== toIndex) {
const reverse = !(fromIndex < toIndex); const reverse = !(fromIndex < toIndex);
while (((elem.rev < toIndex) && !reverse) || ((elem.rev > toIndex) && reverse)) { while (((elem.rev < toIndex) && !reverse) || ((elem.rev > toIndex) && reverse)) {
@ -88,7 +102,7 @@ const loadBroadcastRevisionsJS = () => {
changesets.push(topush.getValue()); changesets.push(topush.getValue());
spans.push(elem.changesets[i].deltaRev); spans.push(elem.changesets[i].deltaRev);
times.push(topush.deltaTime); times.push(topush.deltaTime);
elem = this[elem.rev + elem.changesets[i].deltaRev]; elem = this.revisionInfo[elem.rev + elem.changesets[i].deltaRev] as Revision;
break; break;
} }
} }
@ -108,8 +122,7 @@ const loadBroadcastRevisionsJS = () => {
spans, spans,
times, times,
}; };
}; }
window.revisionInfo = revisionInfo; }
};
exports.loadBroadcastRevisionsJS = loadBroadcastRevisionsJS; export default new RevisionInfo();

View file

@ -1,343 +0,0 @@
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// These parameters were global, now they are injected. A reference to the
// Timeslider controller would probably be more appropriate.
const _ = require('underscore');
const padmodals = require('./pad_modals').padmodals;
const colorutils = require('./colorutils').colorutils;
import html10n from './vendors/html10n';
const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
let BroadcastSlider;
// Hack to ensure timeslider i18n values are in
$("[data-key='timeslider_returnToPad'] > a > span").html(
html10n.get('timeslider.toolbar.returnbutton'));
(() => { // wrap this code in its own namespace
let sliderLength = 1000;
let sliderPos = 0;
let sliderActive = false;
const slidercallbacks = [];
const savedRevisions = [];
let sliderPlaying = false;
const _callSliderCallbacks = (newval) => {
sliderPos = newval;
for (let i = 0; i < slidercallbacks.length; i++) {
slidercallbacks[i](newval);
}
};
const updateSliderElements = () => {
for (let i = 0; i < savedRevisions.length; i++) {
const position = parseInt(savedRevisions[i].attr('pos'));
savedRevisions[i].css(
'left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);
}
$('#ui-slider-handle').css(
'left', sliderPos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));
};
const addSavedRevision = (position, info) => {
const newSavedRevision = $('<div></div>');
newSavedRevision.addClass('star');
newSavedRevision.attr('pos', position);
newSavedRevision.css(
'left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);
$('#ui-slider-bar').append(newSavedRevision);
newSavedRevision.on('mouseup', (evt) => {
BroadcastSlider.setSliderPosition(position);
});
savedRevisions.push(newSavedRevision);
};
/* Begin small 'API' */
const onSlider = (callback) => {
slidercallbacks.push(callback);
};
const getSliderPosition = () => sliderPos;
const setSliderPosition = (newpos) => {
newpos = Number(newpos);
if (newpos < 0 || newpos > sliderLength) return;
if (!newpos) {
newpos = 0; // stops it from displaying NaN if newpos isn't set
}
window.location.hash = `#${newpos}`;
$('#ui-slider-handle').css(
'left', newpos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));
$('a.tlink').map(function () {
$(this).attr('href', $(this).attr('thref').replace('%revision%', newpos));
});
$('#revision_label').html(html10n.get('timeslider.version', {version: newpos}));
$('#leftstar, #leftstep').toggleClass('disabled', newpos === 0);
$('#rightstar, #rightstep').toggleClass('disabled', newpos === sliderLength);
sliderPos = newpos;
_callSliderCallbacks(newpos);
};
const getSliderLength = () => sliderLength;
const setSliderLength = (newlength) => {
sliderLength = newlength;
updateSliderElements();
};
// just take over the whole slider screen with a reconnect message
const showReconnectUI = () => {
padmodals.showModal('disconnected');
};
const setAuthors = (authors) => {
const authorsList = $('#authorsList');
authorsList.empty();
let numAnonymous = 0;
let numNamed = 0;
const colorsAnonymous = [];
_.each(authors, (author) => {
if (author) {
const authorColor = clientVars.colorPalette[author.colorId] || author.colorId;
if (author.name) {
if (numNamed !== 0) authorsList.append(', ');
const textColor =
colorutils.textColorFromBackgroundColor(authorColor, clientVars.skinName);
$('<span />')
.text(author.name || 'unnamed')
.css('background-color', authorColor)
.css('color', textColor)
.addClass('author')
.appendTo(authorsList);
numNamed++;
} else {
numAnonymous++;
if (authorColor) colorsAnonymous.push(authorColor);
}
}
});
if (numAnonymous > 0) {
const anonymousAuthorString = html10n.get('timeslider.unnamedauthors', {num: numAnonymous});
if (numNamed !== 0) {
authorsList.append(` + ${anonymousAuthorString}`);
} else {
authorsList.append(anonymousAuthorString);
}
if (colorsAnonymous.length > 0) {
authorsList.append(' (');
_.each(colorsAnonymous, (color, i) => {
if (i > 0) authorsList.append(' ');
$('<span>&nbsp;</span>')
.css('background-color', color)
.addClass('author author-anonymous')
.appendTo(authorsList);
});
authorsList.append(')');
}
}
if (authors.length === 0) {
authorsList.append(html10n.get('timeslider.toolbar.authorsList'));
}
};
const playButtonUpdater = () => {
if (sliderPlaying) {
if (getSliderPosition() + 1 > sliderLength) {
$('#playpause_button_icon').toggleClass('pause');
sliderPlaying = false;
return;
}
setSliderPosition(getSliderPosition() + 1);
setTimeout(playButtonUpdater, 100);
}
};
const playpause = () => {
$('#playpause_button_icon').toggleClass('pause');
if (!sliderPlaying) {
if (getSliderPosition() === sliderLength) setSliderPosition(0);
sliderPlaying = true;
playButtonUpdater();
} else {
sliderPlaying = false;
}
};
BroadcastSlider = {
onSlider,
getSliderPosition,
setSliderPosition,
getSliderLength,
setSliderLength,
isSliderActive: () => sliderActive,
playpause,
addSavedRevision,
showReconnectUI,
setAuthors,
};
// assign event handlers to html UI elements after page load
fireWhenAllScriptsAreLoaded.push(() => {
$(document).on('keyup', (e) => {
if (!e) e = window.event;
const code = e.keyCode || e.which;
if (code === 37) { // left
if (e.shiftKey) {
$('#leftstar').trigger('click');
} else {
$('#leftstep').trigger('click');
}
} else if (code === 39) { // right
if (e.shiftKey) {
$('#rightstar').trigger('click');
} else {
$('#rightstep').trigger('click');
}
} else if (code === 32) { // spacebar
$('#playpause_button_icon').trigger('click');
}
});
// Resize
$(window).on('resize', () => {
updateSliderElements();
});
// Slider click
$('#ui-slider-bar').on('mousedown', (evt) => {
$('#ui-slider-handle').css('left', (evt.clientX - $('#ui-slider-bar').offset().left));
$('#ui-slider-handle').trigger(evt);
});
// Slider dragging
$('#ui-slider-handle').on('mousedown', function (evt) {
this.startLoc = evt.clientX;
this.currentLoc = parseInt($(this).css('left'));
sliderActive = true;
$(document).on('mousemove', (evt2) => {
$(this).css('pointer', 'move');
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
if (newloc < 0) newloc = 0;
const maxPos = $('#ui-slider-bar').width() - 2;
if (newloc > maxPos) newloc = maxPos;
const version = Math.floor(newloc * sliderLength / maxPos);
$('#revision_label').html(html10n.get('timeslider.version', {version}));
$(this).css('left', newloc);
if (getSliderPosition() !== version) _callSliderCallbacks(version);
});
$(document).on('mouseup', (evt2) => {
$(document).off('mousemove');
$(document).off('mouseup');
sliderActive = false;
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
if (newloc < 0) newloc = 0;
const maxPos = $('#ui-slider-bar').width() - 2;
if (newloc > maxPos) newloc = maxPos;
$(this).css('left', newloc);
setSliderPosition(Math.floor(newloc * sliderLength / maxPos));
if (parseInt($(this).css('left')) < 2) {
$(this).css('left', '2px');
} else {
this.currentLoc = parseInt($(this).css('left'));
}
});
});
// play/pause toggling
$('#playpause_button_icon').on('click', (evt) => {
BroadcastSlider.playpause();
});
// next/prev saved revision and changeset
$('.stepper').on('click', function (evt) {
switch ($(this).attr('id')) {
case 'leftstep':
setSliderPosition(getSliderPosition() - 1);
break;
case 'rightstep':
setSliderPosition(getSliderPosition() + 1);
break;
case 'leftstar': {
let nextStar = 0; // default to first revision in document
for (let i = 0; i < savedRevisions.length; i++) {
const pos = parseInt(savedRevisions[i].attr('pos'));
if (pos < getSliderPosition() && nextStar < pos) nextStar = pos;
}
setSliderPosition(nextStar);
break;
}
case 'rightstar': {
let nextStar = sliderLength; // default to last revision in document
for (let i = 0; i < savedRevisions.length; i++) {
const pos = parseInt(savedRevisions[i].attr('pos'));
if (pos > getSliderPosition() && nextStar > pos) nextStar = pos;
}
setSliderPosition(nextStar);
break;
}
}
});
if (clientVars) {
$('#timeslider-wrapper').show();
if (window.location.hash.length > 1) {
const hashRev = Number(window.location.hash.substr(1));
if (!isNaN(hashRev)) {
// this is necessary because of the socket.io-event which loads the changesets
setTimeout(() => { setSliderPosition(hashRev); }, 1);
}
}
setSliderLength(clientVars.collab_client_vars.rev);
setSliderPosition(clientVars.collab_client_vars.rev);
_.each(clientVars.savedRevisions, (revision) => {
addSavedRevision(revision.revNum, revision);
});
}
});
})();
BroadcastSlider.onSlider((loc) => {
$('#viewlatest').html(
`${loc === BroadcastSlider.getSliderLength() ? 'Viewing' : 'View'} latest content`);
});
return BroadcastSlider;
};
exports.loadBroadcastSliderJS = loadBroadcastSliderJS;

View file

@ -0,0 +1,326 @@
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
import {UserInfo} from "./types/SocketIOMessage";
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// These parameters were global, now they are injected. A reference to the
// Timeslider controller would probably be more appropriate.
import _ from 'underscore';
import {padModals as padmodals} from "./pad_modals";
import colorutils from "./colorutils";
import html10n from './vendors/html10n';
import {PadRevision} from "./types/PadRevision";
class BroadcastSlider {
private sliderLength = 1000;
private sliderPos = 0;
private sliderActive = false;
private slidercallbacks: ((val: number)=>void)[] = [];
private savedRevisions: JQuery<HTMLElement>[] = [];
private sliderPlaying = false;
private fireWhenAllScriptsAreLoaded: Function[] = [];
private startLoc?: number;
private currentLoc?: number;
constructor() {
// Hack to ensure timeslider i18n values are in
$("[data-key='timeslider_returnToPad'] > a > span").html(
html10n.get('timeslider.toolbar.returnbutton'))
// assign event handlers to html UI elements after page load
this.fireWhenAllScriptsAreLoaded.push(() => {
$(document).on('keyup', (e) => {
if (!e) { // @ts-ignore
e = window.event;
}
const code = e.keyCode || e.which;
if (code === 37) { // left
if (e.shiftKey) {
$('#leftstar').trigger('click');
} else {
$('#leftstep').trigger('click');
}
} else if (code === 39) { // right
if (e.shiftKey) {
$('#rightstar').trigger('click');
} else {
$('#rightstep').trigger('click');
}
} else if (code === 32) { // spacebar
$('#playpause_button_icon').trigger('click');
}
});
// Resize
$(window).on('resize', () => {
this.updateSliderElements();
});
// Slider click
$('#ui-slider-bar').on('mousedown', (evt) => {
$('#ui-slider-handle').css('left', (evt.clientX - $('#ui-slider-bar').offset()!.left));
$('#ui-slider-handle').trigger(evt);
});
// Slider dragging
$('#ui-slider-handle').on('mousedown', (evt)=> {
this.startLoc = evt.clientX;
this.currentLoc = parseInt($(this).css('left'));
this.sliderActive = true;
$(document).on('mousemove', (evt2) => {
$(this).css('pointer', 'move');
let newloc = this.currentLoc! + (evt2.clientX - this.startLoc!);
if (newloc < 0) newloc = 0;
const maxPos = $('#ui-slider-bar').width()! - 2;
if (newloc > maxPos) newloc = maxPos;
const version = Math.floor(newloc * this.sliderLength / maxPos);
$('#revision_label').html(html10n.get('timeslider.version', {version}));
$(this).css('left', newloc);
if (this.getSliderPosition() !== version) this.callSliderCallbacks(version);
});
$(document).on('mouseup', (evt2) => {
$(document).off('mousemove');
$(document).off('mouseup');
this.sliderActive = false;
let newloc = this.currentLoc! + (evt2.clientX - this.startLoc!);
if (newloc < 0) newloc = 0;
const maxPos = $('#ui-slider-bar').width()! - 2;
if (newloc > maxPos) newloc = maxPos;
$(this).css('left', newloc);
this.setSliderPosition(Math.floor(newloc * this.sliderLength / maxPos));
if (parseInt($(this).css('left')) < 2) {
$(this).css('left', '2px');
} else {
this.currentLoc = parseInt($(this).css('left'));
}
});
});
// play/pause toggling
$('#playpause_button_icon').on('click', (evt) => {
this.playpause();
});
// next/prev saved revision and changeset
$('.stepper').on('click', (evt)=> {
switch ($(this).attr('id')) {
case 'leftstep':
this.setSliderPosition(this.getSliderPosition() - 1);
break;
case 'rightstep':
this.setSliderPosition(this.getSliderPosition() + 1);
break;
case 'leftstar': {
let nextStar = 0; // default to first revision in document
for (let i = 0; i < this.savedRevisions.length; i++) {
const pos = parseInt(this.savedRevisions[i].attr('pos') as string);
if (pos < this.getSliderPosition() && nextStar < pos) nextStar = pos;
}
this.setSliderPosition(nextStar);
break;
}
case 'rightstar': {
let nextStar = this.sliderLength; // default to last revision in document
for (let i = 0; i < this.savedRevisions.length; i++) {
const pos = parseInt(this.savedRevisions[i].attr('pos') as string);
if (pos > this.getSliderPosition() && nextStar > pos) nextStar = pos;
}
this.setSliderPosition(nextStar);
break;
}
}
});
if (window.clientVars) {
$('#timeslider-wrapper').show();
if (window.location.hash.length > 1) {
const hashRev = Number(window.location.hash.substr(1));
if (!isNaN(hashRev)) {
// this is necessary because of the socket.io-event which loads the changesets
setTimeout(() => { this.setSliderPosition(hashRev); }, 1);
}
}
this.setSliderLength(window.clientVars.collab_client_vars.rev);
this.setSliderPosition(window.clientVars.collab_client_vars.rev);
_.each(window.clientVars.savedRevisions, (revision: PadRevision) => {
this.addSavedRevision(revision.revNum, revision);
});
}
})
this.onSlider((loc) => {
$('#viewlatest').html(
`${loc === this.getSliderLength() ? 'Viewing' : 'View'} latest content`);
})
}
private callSliderCallbacks = (newval: number) => {
this.sliderPos = newval;
for (let i = 0; i < this.slidercallbacks.length; i++) {
this.slidercallbacks[i](newval);
}
}
updateSliderElements = () => {
for (let i = 0; i < this.savedRevisions.length; i++) {
const position = parseInt(this.savedRevisions[i].attr('pos')!);
this.savedRevisions[i].css(
'left', (position * ($('#ui-slider-bar').width()! - 2) / (this.sliderLength * 1.0)) - 1);
}
$('#ui-slider-handle').css(
'left', this.sliderPos * ($('#ui-slider-bar').width()! - 2) / (this.sliderLength * 1.0));
}
addSavedRevision = (position: number, info?:any) => {
const newSavedRevision = $('<div></div>');
newSavedRevision.addClass('star');
newSavedRevision.attr('pos', position);
newSavedRevision.css(
'left', (position * ($('#ui-slider-bar').width()! - 2) / (this.sliderLength * 1.0)) - 1);
$('#ui-slider-bar').append(newSavedRevision);
newSavedRevision.on('mouseup', (evt) => {
this.setSliderPosition(position);
});
this.savedRevisions.push(newSavedRevision);
}
/* Begin small 'API' */
onSlider = (callback: (val: number) => void ) => {
this.slidercallbacks.push(callback);
}
getSliderPosition = () => this.sliderPos
setSliderPosition = (newpos: number) => {
newpos = Number(newpos);
if (newpos < 0 || newpos > this.sliderLength) return;
if (!newpos) {
newpos = 0; // stops it from displaying NaN if newpos isn't set
}
window.location.hash = `#${newpos}`;
$('#ui-slider-handle').css(
'left', newpos * ($('#ui-slider-bar').width()! - 2) / (this.sliderLength * 1.0));
$('a.tlink').map(function () {
$(this).attr('href', $(this).attr('thref')!.replace('%revision%', String(newpos)));
});
$('#revision_label').html(html10n.get('timeslider.version', {version: newpos}));
$('#leftstar, #leftstep').toggleClass('disabled', newpos === 0);
$('#rightstar, #rightstep').toggleClass('disabled', newpos === this.sliderLength);
this.sliderPos = newpos;
this.callSliderCallbacks(newpos);
}
getSliderLength = () => this.sliderLength;
setSliderLength = (newlength: number) => {
this.sliderLength = newlength;
this.updateSliderElements();
}
// just take over the whole slider screen with a reconnect message
showReconnectUI = () => {
padmodals.showModal('disconnected');
}
setAuthors = (authors: UserInfo[]) => {
const authorsList = $('#authorsList');
authorsList.empty();
let numAnonymous = 0;
let numNamed = 0;
const colorsAnonymous: number[] = [];
_.each(authors, (author: UserInfo) => {
if (author) {
const authorColor = window.clientVars.colorPalette[author.colorId] || author.colorId;
if (author.name) {
if (numNamed !== 0) authorsList.append(', ');
const textColor =
colorutils.textColorFromBackgroundColor(authorColor, window.clientVars.skinName);
$('<span />')
.text(author.name || 'unnamed')
.css('background-color', authorColor)
.css('color', textColor)
.addClass('author')
.appendTo(authorsList);
numNamed++;
} else {
numAnonymous++;
if (authorColor) colorsAnonymous.push(authorColor);
}
}
});
if (numAnonymous > 0) {
const anonymousAuthorString = html10n.get('timeslider.unnamedauthors', {num: numAnonymous});
if (numNamed !== 0) {
authorsList.append(` + ${anonymousAuthorString}`);
} else {
authorsList.append(anonymousAuthorString);
}
if (colorsAnonymous.length > 0) {
authorsList.append(' (');
_.each(colorsAnonymous, (color: number, i: number) => {
if (i > 0) authorsList.append(' ');
$('<span>&nbsp;</span>')
.css('background-color', color)
.addClass('author author-anonymous')
.appendTo(authorsList);
});
authorsList.append(')');
}
}
if (authors.length === 0) {
authorsList.append(html10n.get('timeslider.toolbar.authorsList'));
}
}
playButtonUpdater = () => {
if (this.sliderPlaying) {
if (this.getSliderPosition() + 1 > this.sliderLength) {
$('#playpause_button_icon').toggleClass('pause');
this.sliderPlaying = false;
return;
}
this.setSliderPosition(this.getSliderPosition() + 1);
setTimeout(this.playButtonUpdater, 100);
}
}
playpause = () => {
$('#playpause_button_icon').toggleClass('pause');
if (!this.sliderPlaying) {
if (this.getSliderPosition() === this.sliderLength) this.setSliderPosition(0);
this.sliderPlaying = true;
this.playButtonUpdater();
} else {
this.sliderPlaying = false;
}
}
isSliderActive= () => this.sliderActive
}
export default new BroadcastSlider();

View file

@ -1,280 +0,0 @@
'use strict';
/**
* Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const ChatMessage = require('./ChatMessage');
import {padUtils as padutils} from "./pad_utils";
import padcookie from "./pad_cookie";
const Tinycon = require('tinycon/tinycon');
const hooks = require('./pluginfw/hooks');
const padeditor = require('./pad_editor').padeditor;
import html10n from './vendors/html10n';
// Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463
const normalize = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
exports.chat = (() => {
let isStuck = false;
let userAndChat = false;
let chatMentions = 0;
return {
show() {
$('#chaticon').removeClass('visible');
$('#chatbox').addClass('visible');
this.scrollDown(true);
chatMentions = 0;
Tinycon.setBubble(0);
$('.chat-gritter-msg').each(function () {
$.gritter.remove(this.id);
});
},
focus: () => {
setTimeout(() => {
$('#chatinput').trigger('focus');
}, 100);
},
// Make chat stick to right hand side of screen
stickToScreen(fromInitialCall) {
if ($('#options-stickychat').prop('checked')) {
$('#options-stickychat').prop('checked', false);
}
if (pad.settings.hideChat) {
return;
}
this.show();
isStuck = (!isStuck || fromInitialCall);
$('#chatbox').hide();
// Add timeout to disable the chatbox animations
setTimeout(() => {
$('#chatbox, .sticky-container').toggleClass('stickyChat', isStuck);
$('#chatbox').css('display', 'flex');
}, 0);
padcookie.setPref('chatAlwaysVisible', isStuck);
$('#options-stickychat').prop('checked', isStuck);
},
chatAndUsers(fromInitialCall) {
const toEnable = $('#options-chatandusers').is(':checked');
if (toEnable || !userAndChat || fromInitialCall) {
this.stickToScreen(true);
$('#options-stickychat').prop('checked', true);
$('#options-chatandusers').prop('checked', true);
$('#options-stickychat').prop('disabled', true);
userAndChat = true;
} else {
$('#options-stickychat').prop('disabled', false);
userAndChat = false;
}
padcookie.setPref('chatAndUsers', userAndChat);
$('#users, .sticky-container')
.toggleClass('chatAndUsers popup-show stickyUsers', userAndChat);
$('#chatbox').toggleClass('chatAndUsersChat', userAndChat);
},
hide() {
// decide on hide logic based on chat window being maximized or not
if ($('#options-stickychat').prop('checked')) {
this.stickToScreen();
$('#options-stickychat').prop('checked', false);
} else {
$('#chatcounter').text('0');
$('#chaticon').addClass('visible');
$('#chatbox').removeClass('visible');
}
},
scrollDown(force) {
if ($('#chatbox').hasClass('visible')) {
if (force || !this.lastMessage || !this.lastMessage.position() ||
this.lastMessage.position().top < ($('#chattext').outerHeight() + 20)) {
// if we use a slow animate here we can have a race condition
// when a users focus can not be moved away from the last message recieved.
$('#chattext').animate(
{scrollTop: $('#chattext')[0].scrollHeight},
{duration: 400, queue: false});
this.lastMessage = $('#chattext > p').eq(-1);
}
}
},
async send() {
const text = $('#chatinput').val();
if (text.replace(/\s+/, '').length === 0) return;
const message = new ChatMessage(text);
await hooks.aCallAll('chatSendMessage', Object.freeze({message}));
this._pad.collabClient.sendMessage({type: 'CHAT_MESSAGE', message});
$('#chatinput').val('');
},
async addMessage(msg, increment, isHistoryAdd) {
msg = ChatMessage.fromObject(msg);
// correct the time
msg.time += this._pad.clientTimeOffset;
if (!msg.authorId) {
/*
* If, for a bug or a database corruption, the message coming from the
* server does not contain the authorId field (see for example #3731),
* let's be defensive and replace it with "unknown".
*/
msg.authorId = 'unknown';
console.warn(
'The "authorId" field of a chat message coming from the server was not present. ' +
'Replacing with "unknown". This may be a bug or a database corruption.');
}
const authorClass = (authorId) => `author-${authorId.replace(/[^a-y0-9]/g, (c) => {
if (c === '.') return '-';
return `z${c.charCodeAt(0)}z`;
})}`;
// the hook args
const ctx = {
authorName: msg.displayName != null ? msg.displayName : html10n.get('pad.userlist.unnamed'),
author: msg.authorId,
text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'),
message: msg,
rendered: null,
sticky: false,
timestamp: msg.time,
timeStr: (() => {
let minutes = `${new Date(msg.time).getMinutes()}`;
let hours = `${new Date(msg.time).getHours()}`;
if (minutes.length === 1) minutes = `0${minutes}`;
if (hours.length === 1) hours = `0${hours}`;
return `${hours}:${minutes}`;
})(),
duration: 4000,
};
// is the users focus already in the chatbox?
const alreadyFocused = $('#chatinput').is(':focus');
// does the user already have the chatbox open?
const chatOpen = $('#chatbox').hasClass('visible');
// does this message contain this user's name? (is the current user mentioned?)
const wasMentioned =
msg.authorId !== window.clientVars.userId &&
ctx.authorName !== html10n.get('pad.userlist.unnamed') &&
normalize(ctx.text).includes(normalize(ctx.authorName));
// If the user was mentioned, make the message sticky
if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) {
chatMentions++;
Tinycon.setBubble(chatMentions);
ctx.sticky = true;
}
await hooks.aCallAll('chatNewMessage', ctx);
const cls = authorClass(ctx.author);
const chatMsg = ctx.rendered != null ? $(ctx.rendered) : $('<p>')
.attr('data-authorId', ctx.author)
.addClass(cls)
.append($('<b>').text(`${ctx.authorName}:`))
.append($('<span>')
.addClass('time')
.addClass(cls)
// Hook functions are trusted to not introduce an XSS vulnerability by adding
// unescaped user input to ctx.timeStr.
.html(ctx.timeStr))
.append(' ')
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not
// introduce an XSS vulnerability by adding unescaped user input.
.append($('<div>').html(ctx.text).contents());
if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton');
else $('#chattext').append(chatMsg);
chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e));
// should we increment the counter??
if (increment && !isHistoryAdd) {
// Update the counter of unread messages
let count = Number($('#chatcounter').text());
count++;
$('#chatcounter').text(count);
if (!chatOpen && ctx.duration > 0) {
const text = $('<p>')
.append($('<span>').addClass('author-name').text(ctx.authorName))
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted
// to not introduce an XSS vulnerability by adding unescaped user input.
.append($('<div>').html(ctx.text).contents());
text.each((i, e) => html10n.translateElement(html10n.translations, e));
$.gritter.add({
text,
sticky: ctx.sticky,
time: ctx.duration,
position: 'bottom',
class_name: 'chat-gritter-msg',
});
}
}
if (!isHistoryAdd) this.scrollDown();
},
init(pad) {
this._pad = pad;
$('#chatinput').on('keydown', (evt) => {
// If the event is Alt C or Escape & we're already in the chat menu
// Send the users focus back to the pad
if ((evt.altKey === true && evt.which === 67) || evt.which === 27) {
// If we're in chat already..
$(':focus').trigger('blur'); // required to do not try to remove!
padeditor.ace.focus(); // Sends focus back to pad
evt.preventDefault();
return false;
}
});
// Clear the chat mentions when the user clicks on the chat input box
$('#chatinput').on('click', () => {
chatMentions = 0;
Tinycon.setBubble(0);
});
const self = this;
$('body:not(#chatinput)').on('keypress', function (evt) {
if (evt.altKey && evt.which === 67) {
// Alt c focuses on the Chat window
$(this).trigger('blur');
self.show();
$('#chatinput').trigger('focus');
evt.preventDefault();
}
});
$('#chatinput').on('keypress', (evt) => {
// if the user typed enter, fire the send
if (evt.key === 'Enter' && !evt.shiftKey) {
evt.preventDefault();
this.send();
}
});
// initial messages are loaded in pad.js' _afterHandshake
$('#chatcounter').text(0);
$('#chatloadmessagesbutton').on('click', () => {
const start = Math.max(this.historyPointer - 20, 0);
const end = this.historyPointer;
if (start === end) return; // nothing to load
$('#chatloadmessagesbutton').css('display', 'none');
$('#chatloadmessagesball').css('display', 'block');
pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end});
this.historyPointer = start;
});
},
};
})();

293
src/static/js/chat.ts Normal file
View file

@ -0,0 +1,293 @@
'use strict';
import {Pad} from "./pad";
/**
* Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ChatMessage from './ChatMessage';
import {padUtils as padutils} from "./pad_utils";
import padcookie from "./pad_cookie";
import Tinycon from 'tinycon';
const hooks = require('./pluginfw/hooks');
const padeditor = require('./pad_editor').padeditor;
import html10n from './vendors/html10n';
// Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463
const normalize = (s: string) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
export class Chat {
private isStuck = false;
private userAndChat = false;
private chatMentions = 0;
private pad?: Pad
private historyPointer?: number
private lastMessage?: JQuery<HTMLElement>
init(pad: Pad) {
this.pad = pad;
$('#chatinput').on('keydown', (evt) => {
// If the event is Alt C or Escape & we're already in the chat menu
// Send the users focus back to the pad
if ((evt.altKey === true && evt.which === 67) || evt.which === 27) {
// If we're in chat already..
$(':focus').trigger('blur'); // required to do not try to remove!
padeditor.ace.focus(); // Sends focus back to pad
evt.preventDefault();
return false;
}
});
// Clear the chat mentions when the user clicks on the chat input box
$('#chatinput').on('click', () => {
this.chatMentions = 0;
Tinycon.setBubble(0);
});
const self = this;
$('body:not(#chatinput)').on('keypress', function (evt) {
if (evt.altKey && evt.which === 67) {
// Alt c focuses on the Chat window
$(this).trigger('blur');
self.show();
$('#chatinput').trigger('focus');
evt.preventDefault();
}
});
$('#chatinput').on('keypress', (evt) => {
// if the user typed enter, fire the send
if (evt.key === 'Enter' && !evt.shiftKey) {
evt.preventDefault();
this.send();
}
});
// initial messages are loaded in pad.js' _afterHandshake
$('#chatcounter').text(0);
$('#chatloadmessagesbutton').on('click', () => {
const start = Math.max(this.historyPointer! - 20, 0);
const end = this.historyPointer!;
if (start === end) return; // nothing to load
$('#chatloadmessagesbutton').css('display', 'none');
$('#chatloadmessagesball').css('display', 'block');
pad.collabClient!.sendMessage({type: 'GET_CHAT_MESSAGES', start, end});
this.historyPointer = start;
});
}
show() {
$('#chaticon').removeClass('visible');
$('#chatbox').addClass('visible');
this.scrollDown(true);
this.chatMentions = 0;
Tinycon.setBubble(0);
$('.chat-gritter-msg').each(function () {
// @ts-ignore
$.gritter.remove(this.id);
});
}
focus = () => {
setTimeout(() => {
$('#chatinput').trigger('focus');
}, 100);
}
// Make chat stick to right hand side of screen
stickToScreen(fromInitialCall?: boolean) {
if ($('#options-stickychat').prop('checked')) {
$('#options-stickychat').prop('checked', false);
}
if (pad.settings.hideChat) {
return;
}
this.show();
this.isStuck = (!this.isStuck || fromInitialCall)!;
$('#chatbox').hide();
// Add timeout to disable the chatbox animations
setTimeout(() => {
$('#chatbox, .sticky-container').toggleClass('stickyChat', this.isStuck);
$('#chatbox').css('display', 'flex');
}, 0);
padcookie.setPref('chatAlwaysVisible', this.isStuck);
$('#options-stickychat').prop('checked', this.isStuck);
}
chatAndUsers(fromInitialCall: boolean) {
const toEnable = $('#options-chatandusers').is(':checked');
if (toEnable || !this.userAndChat || fromInitialCall) {
this.stickToScreen(true);
$('#options-stickychat').prop('checked', true);
$('#options-chatandusers').prop('checked', true);
$('#options-stickychat').prop('disabled', true);
this.userAndChat = true;
} else {
$('#options-stickychat').prop('disabled', false);
this.userAndChat = false;
}
padcookie.setPref('chatAndUsers', this.userAndChat);
$('#users, .sticky-container')
.toggleClass('chatAndUsers popup-show stickyUsers', this.userAndChat);
$('#chatbox').toggleClass('chatAndUsersChat', this.userAndChat);
}
hide() {
// decide on hide logic based on chat window being maximized or not
if ($('#options-stickychat').prop('checked')) {
this.stickToScreen();
$('#options-stickychat').prop('checked', false);
} else {
$('#chatcounter').text('0');
$('#chaticon').addClass('visible');
$('#chatbox').removeClass('visible');
}
}
scrollDown = (force?:boolean)=> {
if ($('#chatbox').hasClass('visible')) {
if (force || !this.lastMessage || !this.lastMessage.position() ||
this.lastMessage.position().top < ($('#chattext').outerHeight()! + 20)) {
// if we use a slow animate here we can have a race condition
// when a users focus can not be moved away from the last message recieved.
$('#chattext').animate(
{scrollTop: $('#chattext')[0].scrollHeight},
{duration: 400, queue: false});
this.lastMessage = $('#chattext > p').eq(-1);
}
}
}
send = async () => {
const text = $('#chatinput').val() as string;
if (text.replace(/\s+/, '').length === 0) return;
const message = new ChatMessage(text);
await hooks.aCallAll('chatSendMessage', Object.freeze({message}));
this.pad!.collabClient!.sendMessage({type: 'CHAT_MESSAGE', message});
$('#chatinput').val('');
}
addMessage = async (msg: ChatMessage, increment: boolean, isHistoryAdd: boolean) => {
msg = ChatMessage.fromObject(msg);
// correct the time
msg.time! += this.pad!.clientTimeOffset!;
if (!msg.authorId) {
/*
* If, for a bug or a database corruption, the message coming from the
* server does not contain the authorId field (see for example #3731),
* let's be defensive and replace it with "unknown".
*/
msg.authorId = 'unknown';
console.warn(
'The "authorId" field of a chat message coming from the server was not present. ' +
'Replacing with "unknown". This may be a bug or a database corruption.');
}
const authorClass = (authorId: string) => `author-${authorId.replace(/[^a-y0-9]/g, (c) => {
if (c === '.') return '-';
return `z${c.charCodeAt(0)}z`;
})}`;
// the hook args
const ctx = {
authorName: msg.displayName != null ? msg.displayName : html10n.get('pad.userlist.unnamed'),
author: msg.authorId,
text: padutils.escapeHtmlWithClickableLinks(msg.text!, '_blank'),
message: msg,
rendered: null,
sticky: false,
timestamp: msg.time,
timeStr: (() => {
let minutes = `${new Date(msg.time!).getMinutes()}`;
let hours = `${new Date(msg.time!).getHours()}`;
if (minutes.length === 1) minutes = `0${minutes}`;
if (hours.length === 1) hours = `0${hours}`;
return `${hours}:${minutes}`;
})(),
duration: 4000,
};
// is the users focus already in the chatbox?
const alreadyFocused = $('#chatinput').is(':focus');
// does the user already have the chatbox open?
const chatOpen = $('#chatbox').hasClass('visible');
// does this message contain this user's name? (is the current user mentioned?)
const wasMentioned =
msg.authorId !== window.clientVars.userId &&
ctx.authorName !== html10n.get('pad.userlist.unnamed') &&
normalize(ctx.text).includes(normalize(ctx.authorName));
// If the user was mentioned, make the message sticky
if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) {
this.chatMentions++;
Tinycon.setBubble(this.chatMentions);
ctx.sticky = true;
}
await hooks.aCallAll('chatNewMessage', ctx);
const cls = authorClass(ctx.author);
const chatMsg = ctx.rendered != null ? $(ctx.rendered) : $('<p>')
.attr('data-authorId', ctx.author)
.addClass(cls)
.append($('<b>').text(`${ctx.authorName}:`))
.append($('<span>')
.addClass('time')
.addClass(cls)
// Hook functions are trusted to not introduce an XSS vulnerability by adding
// unescaped user input to ctx.timeStr.
.html(ctx.timeStr))
.append(' ')
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not
// introduce an XSS vulnerability by adding unescaped user input.
.append($('<div>').html(ctx.text).contents());
if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton');
else $('#chattext').append(chatMsg);
chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e));
// should we increment the counter??
if (increment && !isHistoryAdd) {
// Update the counter of unread messages
let count = Number($('#chatcounter').text());
count++;
$('#chatcounter').text(count);
if (!chatOpen && ctx.duration > 0) {
const text = $('<p>')
.append($('<span>').addClass('author-name').text(ctx.authorName))
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted
// to not introduce an XSS vulnerability by adding unescaped user input.
.append($('<div>').html(ctx.text).contents());
text.each((i, e) => html10n.translateElement(html10n.translations, e));
// @ts-ignore
$.gritter.add({
text,
sticky: ctx.sticky,
time: ctx.duration,
position: 'bottom',
class_name: 'chat-gritter-msg',
});
}
}
if (!isHistoryAdd) this.scrollDown();
}
}
export const chat = new Chat()

View file

@ -367,18 +367,17 @@ export class CollabClient {
tellAceAuthorInfo = (userId: string, colorId: number|object, inactive?: boolean) => { tellAceAuthorInfo = (userId: string, colorId: number|object, inactive?: boolean) => {
if (typeof colorId === 'number') { if (typeof colorId === 'number') {
// @ts-ignore
colorId = window.clientVars.colorPalette[colorId]; colorId = window.clientVars.colorPalette[colorId];
} }
const cssColor = colorId; const cssColor = colorId;
if (inactive) { if (inactive) {
// @ts-ignore
this.editor.setAuthorInfo(userId, { this.editor.setAuthorInfo(userId, {
bgcolor: cssColor, bgcolor: cssColor,
fade: 0.5, fade: 0.5,
}); });
} else { } else {
// @ts-ignore
this.editor.setAuthorInfo(userId, { this.editor.setAuthorInfo(userId, {
bgcolor: cssColor, bgcolor: cssColor,
}); });

View file

@ -142,11 +142,11 @@ class ContentCollector {
private selEnd = [-1, -1]; private selEnd = [-1, -1];
private collectStyles: boolean; private collectStyles: boolean;
private apool: AttributePool; private apool: AttributePool;
private className2Author: (c: string) => string; private className2Author?: (c: string) => string;
private breakLine?: boolean private breakLine?: boolean
private abrowser?: null|BrowserDetector; private abrowser?: null|BrowserDetector;
constructor(collectStyles: boolean, abrowser: null, apool: AttributePool, className2Author: (c: string)=>string) { constructor(collectStyles: boolean, abrowser: null, apool: AttributePool, className2Author?: (c: string)=>string) {
this.blockElems = { this.blockElems = {
div: 1, div: 1,
p: 1, p: 1,
@ -352,7 +352,7 @@ class ContentCollector {
this.incrementAttrib(state, na); this.incrementAttrib(state, na);
} }
collectContent = (node: ContentElem, state: ContentCollectorState)=> { collectContent = (node: ContentElem, state?: ContentCollectorState)=> {
let unsupportedElements = null; let unsupportedElements = null;
if (!state) { if (!state) {
state = { state = {
@ -764,3 +764,5 @@ class ContentCollector {
}; };
} }
} }
export default ContentCollector

View file

@ -410,7 +410,7 @@ export class Pad {
collabDiagnosticInfo?: any collabDiagnosticInfo?: any
}; };
private initTime: number; private initTime: number;
private clientTimeOffset: null | number; public clientTimeOffset: null | number;
_messageQ: MessageQueue; _messageQ: MessageQueue;
private padOptions: PadOption; private padOptions: PadOption;
settings: PadSettings = { settings: PadSettings = {

View file

@ -62,7 +62,7 @@ class PadCookie {
return this.readPrefs_()[prefName]; return this.readPrefs_()[prefName];
} }
setPref(prefName: string, value: string) { setPref(prefName: string, value: any) {
const prefs = this.readPrefs_(); const prefs = this.readPrefs_();
prefs[prefName] = value; prefs[prefName] = value;
this.writePrefs_(prefs); this.writePrefs_(prefs);

View file

@ -629,7 +629,7 @@ class PadUserList {
if (!colorPickerSetup) { if (!colorPickerSetup) {
const colorsList = $('#colorpickerswatches'); const colorsList = $('#colorpickerswatches');
for (let i = 0; i < palette.length; i++) { for (let i = 0; i < palette.length!; i++) {
const li = $('<li>', { const li = $('<li>', {
style: `background: ${palette[i]};`, style: `background: ${palette[i]};`,
}); });

View file

@ -0,0 +1,6 @@
import {IPluginInfo} from "live-plugin-manager";
export type IPluginInfoExtended = IPluginInfo & {
path?: string
realPath? : string
}

View file

@ -6,6 +6,7 @@ import {dependencies, name} from '../../../package.json'
import {pathToFileURL} from 'node:url'; import {pathToFileURL} from 'node:url';
const settings = require('../../../node/utils/Settings'); const settings = require('../../../node/utils/Settings');
import {readFileSync} from "fs"; import {readFileSync} from "fs";
import {IPluginInfoExtended} from "./IPluginInfoExtended";
export class LinkInstaller { export class LinkInstaller {
private livePluginManager: PluginManager; private livePluginManager: PluginManager;
@ -46,7 +47,7 @@ export class LinkInstaller {
public async installPlugin(pluginName: string, version?: string) { public async installPlugin(pluginName: string, version?: string) {
if (version) { if (version) {
const installedPlugin = await this.livePluginManager.install(pluginName, version); const installedPlugin = await this.livePluginManager.install(pluginName, version) as IPluginInfoExtended;
this.linkDependency(pluginName) this.linkDependency(pluginName)
await this.checkLinkedDependencies(installedPlugin) await this.checkLinkedDependencies(installedPlugin)
} else { } else {
@ -57,7 +58,7 @@ export class LinkInstaller {
} }
public async listPlugins() { public async listPlugins() {
const plugins = this.livePluginManager.list() const plugins = this.livePluginManager.list() as IPluginInfoExtended[]
if (plugins && plugins.length > 0 && this.loadedPlugins.length == 0) { if (plugins && plugins.length > 0 && this.loadedPlugins.length == 0) {
this.loadedPlugins = plugins this.loadedPlugins = plugins
// Check already installed plugins // Check already installed plugins
@ -224,7 +225,6 @@ export class LinkInstaller {
} }
} }
private async checkLinkedDependencies(plugin: IPluginInfo) { private async checkLinkedDependencies(plugin: IPluginInfo) {
// Check if the plugin really exists at source // Check if the plugin really exists at source
try { try {

View file

@ -1,47 +0,0 @@
'use strict';
const pluginUtils = require('./shared');
const defs = require('./plugin_defs');
exports.baseURL = '';
exports.ensure = (cb) => !defs.loaded ? exports.update(cb) : cb();
exports.update = async (modules) => {
const data = await jQuery.getJSON(
`${exports.baseURL}pluginfw/plugin-definitions.json?v=${clientVars.randomVersionString}`);
defs.plugins = data.plugins;
defs.parts = data.parts;
defs.hooks = pluginUtils.extractHooks(defs.parts, 'client_hooks', null, modules);
defs.loaded = true;
};
const adoptPluginsFromAncestorsOf = (frame) => {
// Bind plugins with parent;
let parentRequire = null;
try {
while ((frame = frame.parent)) {
if (typeof (frame.require) !== 'undefined') {
parentRequire = frame.require;
break;
}
}
} catch (error) {
// Silence (this can only be a XDomain issue).
console.error(error);
}
if (!parentRequire) throw new Error('Parent plugins could not be found.');
const ancestorPluginDefs = parentRequire('ep_etherpad-lite/static/js/pluginfw/plugin_defs');
defs.hooks = ancestorPluginDefs.hooks;
defs.loaded = ancestorPluginDefs.loaded;
defs.parts = ancestorPluginDefs.parts;
defs.plugins = ancestorPluginDefs.plugins;
const ancestorPlugins = parentRequire('ep_etherpad-lite/static/js/pluginfw/client_plugins');
exports.baseURL = ancestorPlugins.baseURL;
exports.ensure = ancestorPlugins.ensure;
exports.update = ancestorPlugins.update;
};
exports.adoptPluginsFromAncestorsOf = adoptPluginsFromAncestorsOf;

View file

@ -0,0 +1,18 @@
'use strict';
import {pluginDefs, PluginResp} from './plugin_defs'
import {extractHooks} from "./shared";
export const baseURL = '';
export const ensure = (cb: Function) => !pluginDefs.isLoaded() ? update(cb) : cb();
export const update = async (modules: Function) => {
const data = await jQuery.getJSON(
`${baseURL}pluginfw/plugin-definitions.json?v=${window.clientVars.randomVersionString}`) as PluginResp;
pluginDefs.setParts(data.parts)
pluginDefs.setPlugins(data.plugins)
const hooks = extractHooks(pluginDefs.getParts(), 'client_hooks', null, modules)!
pluginDefs.setHooks(hooks)
pluginDefs.setLoaded(true)
};

View file

@ -1,6 +1,9 @@
'use strict'; 'use strict';
const pluginDefs = require('./plugin_defs'); import {PluginHook} from "./plugin_defs";
import {MapArrayType} from "../../../node/types/MapType";
import {pluginDefs} from './plugin_defs';
// Maps the name of a server-side hook to a string explaining the deprecation // Maps the name of a server-side hook to a string explaining the deprecation
// (e.g., 'use the foo hook instead'). // (e.g., 'use the foo hook instead').
@ -10,12 +13,12 @@ const pluginDefs = require('./plugin_defs');
// const hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks'); // const hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks');
// hooks.deprecationNotices.fooBar = 'use the newSpiffy hook instead'; // hooks.deprecationNotices.fooBar = 'use the newSpiffy hook instead';
// //
exports.deprecationNotices = {}; export const deprecationNotices: MapArrayType<string> = {};
const deprecationWarned = {}; const deprecationWarned: MapArrayType<boolean> = {};
const checkDeprecation = (hook) => { const checkDeprecation = (hook: PluginHook) => {
const notice = exports.deprecationNotices[hook.hook_name]; const notice = deprecationNotices[hook.hook_name];
if (notice == null) return; if (notice == null) return;
if (deprecationWarned[hook.hook_fn_name]) return; if (deprecationWarned[hook.hook_fn_name]) return;
console.warn(`${hook.hook_name} hook used by the ${hook.part.plugin} plugin ` + console.warn(`${hook.hook_name} hook used by the ${hook.part.plugin} plugin ` +
@ -26,7 +29,7 @@ const checkDeprecation = (hook) => {
// Calls the node-style callback when the Promise settles. Unlike util.callbackify, this takes a // Calls the node-style callback when the Promise settles. Unlike util.callbackify, this takes a
// Promise (rather than a function that returns a Promise), and it returns a Promise (rather than a // Promise (rather than a function that returns a Promise), and it returns a Promise (rather than a
// function that returns undefined). // function that returns undefined).
const attachCallback = (p, cb) => p.then( const attachCallback = (p: Promise<any>, cb: Function) => p.then(
(val) => cb(null, val), (val) => cb(null, val),
// Callbacks often only check the truthiness, not the nullness, of the first parameter. To avoid // Callbacks often only check the truthiness, not the nullness, of the first parameter. To avoid
// problems, always pass a truthy value as the first argument if the Promise is rejected. // problems, always pass a truthy value as the first argument if the Promise is rejected.
@ -35,7 +38,7 @@ const attachCallback = (p, cb) => p.then(
// Normalizes the value provided by hook functions so that it is always an array. `undefined` (but // Normalizes the value provided by hook functions so that it is always an array. `undefined` (but
// not `null`!) becomes an empty array, array values are returned unmodified, and non-array values // not `null`!) becomes an empty array, array values are returned unmodified, and non-array values
// are wrapped in an array (so `null` becomes `[null]`). // are wrapped in an array (so `null` becomes `[null]`).
const normalizeValue = (val) => { const normalizeValue = (val: string|number|string[]|number[]) => {
// `undefined` is treated the same as `[]`. IMPORTANT: `null` is *not* treated the same as `[]` // `undefined` is treated the same as `[]`. IMPORTANT: `null` is *not* treated the same as `[]`
// because some hooks use `null` as a special value. // because some hooks use `null` as a special value.
if (val === undefined) return []; if (val === undefined) return [];
@ -44,7 +47,7 @@ const normalizeValue = (val) => {
}; };
// Flattens the array one level. // Flattens the array one level.
const flatten1 = (array) => array.reduce((a, b) => a.concat(b), []); const flatten1 = (array: any[]) => array.reduce<any[]>((a, b) => a.concat(b), []);
// Calls the hook function synchronously and returns the value provided by the hook function (via // Calls the hook function synchronously and returns the value provided by the hook function (via
// callback or return value). // callback or return value).
@ -75,16 +78,25 @@ const flatten1 = (array) => array.reduce((a, b) => a.concat(b), []);
// See the tests in src/tests/backend/specs/hooks.js for examples of supported and prohibited // See the tests in src/tests/backend/specs/hooks.js for examples of supported and prohibited
// behaviors. // behaviors.
// //
const callHookFnSync = (hook, context) => { type OutCome = {
state: string,
err: Error|null,
val: any,
how: string,
}
const callHookFnSync = (hook: PluginHook, context: {
}) => {
checkDeprecation(hook); checkDeprecation(hook);
// This var is used to keep track of whether the hook function already settled. // This var is used to keep track of whether the hook function already settled.
let outcome; let outcome: OutCome;
// This is used to prevent recursion. // This is used to prevent recursion.
let doubleSettleErr; let doubleSettleErr;
const settle = (err, val, how) => { const settle = (err: Error|null, val: any|null, how: string) => {
doubleSettleErr = null; doubleSettleErr = null;
const state = err == null ? 'resolved' : 'rejected'; const state = err == null ? 'resolved' : 'rejected';
if (outcome != null) { if (outcome != null) {
@ -113,14 +125,14 @@ const callHookFnSync = (hook, context) => {
// IMPORTANT: This callback must return `undefined` so that a hook function can safely do // IMPORTANT: This callback must return `undefined` so that a hook function can safely do
// `return callback(value);` for backwards compatibility. // `return callback(value);` for backwards compatibility.
const callback = (ret) => { const callback = (ret: string) => {
settle(null, ret, 'callback'); settle(null, ret, 'callback');
}; };
let val; let val;
try { try {
val = hook.hook_fn(hook.hook_name, context, callback); val = hook.hook_fn(hook.hook_name, context, callback);
} catch (err) { } catch (err:any) {
if (err === doubleSettleErr) throw err; // Avoid recursion. if (err === doubleSettleErr) throw err; // Avoid recursion.
try { try {
settle(err, null, 'thrown exception'); settle(err, null, 'thrown exception');
@ -137,6 +149,7 @@ const callHookFnSync = (hook, context) => {
// IMPORTANT: This MUST check for undefined -- not nullish -- because some hooks intentionally use // IMPORTANT: This MUST check for undefined -- not nullish -- because some hooks intentionally use
// null as a special value. // null as a special value.
if (val === undefined) { if (val === undefined) {
// @ts-ignore
if (outcome != null) return outcome.val; // Already settled via callback. if (outcome != null) return outcome.val; // Already settled via callback.
if (hook.hook_fn.length >= 3) { if (hook.hook_fn.length >= 3) {
console.error(`UNSETTLED FUNCTION BUG IN HOOK FUNCTION (plugin: ${hook.part.plugin}, ` + console.error(`UNSETTLED FUNCTION BUG IN HOOK FUNCTION (plugin: ${hook.part.plugin}, ` +
@ -173,7 +186,7 @@ const callHookFnSync = (hook, context) => {
} }
settle(null, val, 'returned value'); settle(null, val, 'returned value');
return outcome.val; return outcome!.val;
}; };
// DEPRECATED: Use `callAllSerial()` or `aCallAll()` instead. // DEPRECATED: Use `callAllSerial()` or `aCallAll()` instead.
@ -189,9 +202,11 @@ const callHookFnSync = (hook, context) => {
// 1. Collect all values returned by the hook functions into an array. // 1. Collect all values returned by the hook functions into an array.
// 2. Convert each `undefined` entry into `[]`. // 2. Convert each `undefined` entry into `[]`.
// 3. Flatten one level. // 3. Flatten one level.
exports.callAll = (hookName, context) => { export const callAll = (hookName: string, context?: {
}) => {
if (context == null) context = {}; if (context == null) context = {};
const hooks = pluginDefs.hooks[hookName] || []; const hooks = pluginDefs.getHooks()[hookName] || [];
return flatten1(hooks.map((hook) => normalizeValue(callHookFnSync(hook, context)))); return flatten1(hooks.map((hook) => normalizeValue(callHookFnSync(hook, context))));
}; };
@ -230,13 +245,13 @@ exports.callAll = (hookName, context) => {
// See the tests in src/tests/backend/specs/hooks.js for examples of supported and prohibited // See the tests in src/tests/backend/specs/hooks.js for examples of supported and prohibited
// behaviors. // behaviors.
// //
const callHookFnAsync = async (hook, context) => { const callHookFnAsync = async (hook: PluginHook, context: {}) => {
checkDeprecation(hook); checkDeprecation(hook);
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
// This var is used to keep track of whether the hook function already settled. // This var is used to keep track of whether the hook function already settled.
let outcome; let outcome: OutCome;
const settle = (err, val, how) => { const settle = (err: Error|null, val: string|null, how: string) => {
const state = err == null ? 'resolved' : 'rejected'; const state = err == null ? 'resolved' : 'rejected';
if (outcome != null) { if (outcome != null) {
// It was already settled, which indicates a bug. // It was already settled, which indicates a bug.
@ -258,7 +273,7 @@ const callHookFnAsync = async (hook, context) => {
// IMPORTANT: This callback must return `undefined` so that a hook function can safely do // IMPORTANT: This callback must return `undefined` so that a hook function can safely do
// `return callback(value);` for backwards compatibility. // `return callback(value);` for backwards compatibility.
const callback = (ret) => { const callback = (ret:any) => {
// Wrap ret in a Promise so that a hook function can do `callback(asyncFunction());`. Note: If // Wrap ret in a Promise so that a hook function can do `callback(asyncFunction());`. Note: If
// ret is a Promise (or other thenable), Promise.resolve() will flatten it into this new // ret is a Promise (or other thenable), Promise.resolve() will flatten it into this new
// Promise. // Promise.
@ -270,7 +285,7 @@ const callHookFnAsync = async (hook, context) => {
let ret; let ret;
try { try {
ret = hook.hook_fn(hook.hook_name, context, callback); ret = hook.hook_fn(hook.hook_name, context, callback);
} catch (err) { } catch (err:any) {
try { try {
settle(err, null, 'thrown exception'); settle(err, null, 'thrown exception');
} catch (doubleSettleErr) { } catch (doubleSettleErr) {
@ -342,24 +357,26 @@ const callHookFnAsync = async (hook, context) => {
// 2. Convert each `undefined` entry into `[]`. // 2. Convert each `undefined` entry into `[]`.
// 3. Flatten one level. // 3. Flatten one level.
// If cb is non-null, this function resolves to the value returned by cb. // If cb is non-null, this function resolves to the value returned by cb.
exports.aCallAll = async (hookName, context, cb = null) => { export const aCallAll = async (hookName: string, context?: {}, cb = null): Promise<any[]> => {
if (cb != null) return await attachCallback(exports.aCallAll(hookName, context), cb); if (cb != null) return await attachCallback(aCallAll(hookName, context), cb);
if (context == null) context = {}; if (context == null) context = {};
const hooks = pluginDefs.hooks[hookName] || []; const hooks = pluginDefs.getHooks()[hookName] || [];
const results = await Promise.all( const results = await Promise.all(
hooks.map(async (hook) => normalizeValue(await callHookFnAsync(hook, context)))); hooks.map(async (hook) => normalizeValue(await callHookFnAsync(hook, context) as any)));
return flatten1(results); return flatten1(results);
}; };
// Like `aCallAll()` except the hook functions are called one at a time instead of concurrently. // Like `aCallAll()` except the hook functions are called one at a time instead of concurrently.
// Only use this function if the hook functions must be called one at a time, otherwise use // Only use this function if the hook functions must be called one at a time, otherwise use
// `aCallAll()`. // `aCallAll()`.
exports.callAllSerial = async (hookName, context) => { export const callAllSerial = async (hookName: string, context: {
}) => {
if (context == null) context = {}; if (context == null) context = {};
const hooks = pluginDefs.hooks[hookName] || []; const hooks = pluginDefs.getHooks()[hookName] || [];
const results = []; const results = [];
for (const hook of hooks) { for (const hook of hooks) {
results.push(normalizeValue(await callHookFnAsync(hook, context))); results.push(normalizeValue(await callHookFnAsync(hook, context) as any));
} }
return flatten1(results); return flatten1(results);
}; };
@ -367,10 +384,10 @@ exports.callAllSerial = async (hookName, context) => {
// DEPRECATED: Use `aCallFirst()` instead. // DEPRECATED: Use `aCallFirst()` instead.
// //
// Like `aCallFirst()`, but synchronous. Hook functions must provide their values synchronously. // Like `aCallFirst()`, but synchronous. Hook functions must provide their values synchronously.
exports.callFirst = (hookName, context) => { export const callFirst = (hookName: string, context: {}) => {
if (context == null) context = {}; if (context == null) context = {};
const predicate = (val) => val.length; const predicate = (val: (string|number)[]) => val.length;
const hooks = pluginDefs.hooks[hookName] || []; const hooks = pluginDefs.getHooks()[hookName] || [];
for (const hook of hooks) { for (const hook of hooks) {
const val = normalizeValue(callHookFnSync(hook, context)); const val = normalizeValue(callHookFnSync(hook, context));
if (predicate(val)) return val; if (predicate(val)) return val;
@ -399,22 +416,24 @@ exports.callFirst = (hookName, context) => {
// If cb is nullish, resolves to an array that is either the normalized value that satisfied the // If cb is nullish, resolves to an array that is either the normalized value that satisfied the
// predicate or empty if the predicate was never satisfied. If cb is non-nullish, resolves to the // predicate or empty if the predicate was never satisfied. If cb is non-nullish, resolves to the
// value returned from cb(). // value returned from cb().
exports.aCallFirst = async (hookName, context, cb = null, predicate = null) => { export const aCallFirst = async (hookName: string, context:{}, cb: Function|null = null, predicate: ((val: any[])=>number)|null = null):Promise<any> => {
if (cb != null) { if (cb != null) {
return await attachCallback(exports.aCallFirst(hookName, context, null, predicate), cb); return await attachCallback(aCallFirst(hookName, context, null, predicate), cb);
} }
if (context == null) context = {}; if (context == null) context = {};
if (predicate == null) predicate = (val) => val.length; if (predicate == null) {
const hooks = pluginDefs.hooks[hookName] || []; predicate = (val) => val.length;
}
const hooks = pluginDefs.getHooks()[hookName] || [];
for (const hook of hooks) { for (const hook of hooks) {
const val = normalizeValue(await callHookFnAsync(hook, context)); const val = normalizeValue(await callHookFnAsync(hook, context) as any);
if (predicate(val)) return val; if (predicate(val)) return val;
} }
return []; return [];
}; };
exports.exportedForTestingOnly = { export const exportedForTestingOnly = {
callHookFnAsync, callHookFnAsync,
callHookFnSync, callHookFnSync,
deprecationWarned, deprecationWarned,
}; }

View file

@ -1,28 +0,0 @@
'use strict';
// This module contains processed plugin definitions. The data structures in this file are set by
// plugins.js (server) or client_plugins.js (client).
// Maps a hook name to a list of hook objects. Each hook object has the following properties:
// * hook_name: Name of the hook.
// * hook_fn: Plugin-supplied hook function.
// * hook_fn_name: Name of the hook function, with the form <filename>:<functionName>.
// * part: The ep.json part object that declared the hook. See exports.plugins.
exports.hooks = {};
// Whether the plugins have been loaded.
exports.loaded = false;
// Topologically sorted list of parts from exports.plugins.
exports.parts = [];
// Maps the name of a plugin to the plugin's definition provided in ep.json. The ep.json object is
// augmented with additional metadata:
// * parts: Each part from the ep.json object is augmented with the following properties:
// - plugin: The name of the plugin.
// - full_name: Equal to <plugin>/<name>.
// * package (server-side only): Object containing details about the plugin package:
// - version
// - path
// - realPath
exports.plugins = {};

View file

@ -0,0 +1,98 @@
'use strict';
// This module contains processed plugin definitions. The data structures in this file are set by
// plugins.js (server) or client_plugins.js (client).
// Maps a hook name to a list of hook objects. Each hook object has the following properties:
// * hook_name: Name of the hook.
// * hook_fn: Plugin-supplied hook function.
// * hook_fn_name: Name of the hook function, with the form <filename>:<functionName>.
// * part: The ep.json part object that declared the hook. See exports.plugins.
import {MapArrayType} from "../../../node/types/MapType";
class PluginDef {
private hooks: MapArrayType<PluginHook[]>
private loaded: boolean
private parts: MappedPlugin[]
private plugins: MapArrayType<any>
constructor() {
this.hooks = {}
this.loaded = false
// Topologically sorted list of parts from exports.plugins.
this.parts = []
// Maps the name of a plugin to the plugin's definition provided in ep.json. The ep.json object is
// augmented with additional metadata:
// * parts: Each part from the ep.json object is augmented with the following properties:
// - plugin: The name of the plugin.
// - full_name: Equal to <plugin>/<name>.
// * package (server-side only): Object containing details about the plugin package:
// - version
// - path
// - realPath
this.plugins = {}
}
getHooks() {
return this.hooks
}
isLoaded() {
return this.loaded
}
getParts() {
return this.parts
}
getPlugins() {
return this.plugins
}
setHooks(hooks: MapArrayType<PluginHook[]>) {
this.hooks = hooks
}
setLoaded(loaded: boolean) {
this.loaded = loaded
}
setParts(parts: any[]) {
this.parts = parts
}
setPlugins(plugins: MapArrayType<any>) {
this.plugins = plugins
}
}
export type PluginResp = {
plugins: MapArrayType<Plugin>
parts: MappedPlugin[]
}
export type MappedPlugin = Part& {
plugin: string
full_name: string
}
export type Plugin = {
parts: Part[]
}
export type Part = {
name: string,
client_hooks: MapArrayType<string>,
hooks: MapArrayType<string>
pre?: string[]
post?: string[]
plugin?: string
}
export type PluginHook = {
hook_name: string
hook_fn: Function
hook_fn_name: string
part: Part
}
export const pluginDefs = new PluginDef()

View file

@ -1,13 +1,16 @@
'use strict'; 'use strict';
import {Part} from "./plugin_defs";
const fs = require('fs').promises; const fs = require('fs').promises;
const hooks = require('./hooks'); const hooks = require('./hooks');
const log4js = require('log4js'); import log4js from 'log4js';
const path = require('path'); import path from 'path';
const runCmd = require('../../../node/utils/run_cmd'); const runCmd = require('../../../node/utils/run_cmd');
const tsort = require('./tsort'); import {TSort} from './tsort';
const pluginUtils = require('./shared'); const pluginUtils = require('./shared');
const defs = require('./plugin_defs'); import {pluginDefs} from './plugin_defs';
import {IPluginInfo} from "live-plugin-manager";
const settings = require('../../../node/utils/Settings'); const settings = require('../../../node/utils/Settings');
const logger = log4js.getLogger('plugins'); const logger = log4js.getLogger('plugins');
@ -18,53 +21,54 @@ const logger = log4js.getLogger('plugins');
const version = await runCmd(['pnpm', '--version'], {stdio: [null, 'string']}); const version = await runCmd(['pnpm', '--version'], {stdio: [null, 'string']});
logger.info(`pnpm --version: ${version}`); logger.info(`pnpm --version: ${version}`);
} catch (err) { } catch (err) {
// @ts-ignore
logger.error(`Failed to get pnpm version: ${err.stack || err}`); logger.error(`Failed to get pnpm version: ${err.stack || err}`);
// This isn't a fatal error so don't re-throw. // This isn't a fatal error so don't re-throw.
} }
})(); })();
exports.prefix = 'ep_'; export const prefix = 'ep_';
exports.formatPlugins = () => Object.keys(defs.plugins).join(', '); export const formatPlugins = () => Object.keys(pluginDefs.getPlugins()).join(', ');
exports.getPlugins = () => Object.keys(defs.plugins); export const getPlugins = () => Object.keys(pluginDefs.getPlugins());
exports.formatParts = () => defs.parts.map((part) => part.full_name).join('\n'); export const formatParts = () => pluginDefs.getParts().map((part) => part.full_name).join('\n');
exports.getParts = () => defs.parts.map((part) => part.full_name); export const getParts = () => pluginDefs.getParts().map((part) => part.full_name);
const sortHooks = (hookSetName, hooks) => { const sortHooks = (hookSetName: string, hooks: Map<string, Map<string,Map<string, string>>>) => {
for (const [pluginName, def] of Object.entries(defs.plugins)) { for (const [pluginName, def] of Object.entries(pluginDefs.getPlugins())) {
for (const part of def.parts) { for (const part of def.parts) {
for (const [hookName, hookFnName] of Object.entries(part[hookSetName] || {})) { for (const [hookName, hookFnName] of Object.entries(part[hookSetName] || {})) {
let hookEntry = hooks.get(hookName); let hookEntry = hooks.get(hookName);
if (!hookEntry) { if (!hookEntry) {
hookEntry = new Map(); hookEntry = new Map<string,Map<string, string>>();
hooks.set(hookName, hookEntry); hooks.set(hookName, hookEntry);
} }
let pluginEntry = hookEntry.get(pluginName); let pluginEntry = hookEntry.get(pluginName);
if (!pluginEntry) { if (!pluginEntry) {
pluginEntry = new Map(); pluginEntry = new Map<string, string>();
hookEntry.set(pluginName, pluginEntry); hookEntry.set(pluginName, pluginEntry);
} }
pluginEntry.set(part.name, hookFnName); pluginEntry.set(part.name, hookFnName as string);
} }
} }
} }
}; };
exports.getHooks = (hookSetName) => { export const getHooks = (hookSetName: string) => {
const hooks = new Map(); const hooks = new Map();
sortHooks(hookSetName, hooks); sortHooks(hookSetName, hooks);
return hooks; return hooks;
}; };
exports.formatHooks = (hookSetName, html) => { export const formatHooks = (hookSetName: string, html: string|false) => {
let hooks = new Map(); let hooks:Map<string, Map<string,Map<string, string>>> = new Map();
sortHooks(hookSetName, hooks); sortHooks(hookSetName, hooks);
const lines = []; const lines = [];
const sortStringKeys = (a, b) => String(a[0]).localeCompare(b[0]); const sortStringKeys = (a: [string, Map<string, Map<string, string>>]| [string, Map<string, string>]|[string,string], b: [string, Map<string, Map<string, string>>]|[string,string]| [string, Map<string, string>]) => String(a[0]).localeCompare(b[0]);
if (html) lines.push('<dl>'); if (html) lines.push('<dl>');
hooks = new Map([...hooks].sort(sortStringKeys)); hooks = new Map([...hooks].sort(sortStringKeys));
for (const [hookName, hookEntry] of hooks) { for (const [hookName, hookEntry] of hooks) {
@ -88,20 +92,20 @@ exports.formatHooks = (hookSetName, html) => {
return lines.join('\n'); return lines.join('\n');
}; };
exports.pathNormalization = (part, hookFnName, hookName) => { export const pathNormalization = (part: Part, hookFnName: string, hookName: string) => {
const tmp = hookFnName.split(':'); // hookFnName might be something like 'C:\\foo.js:myFunc'. const tmp = hookFnName.split(':'); // hookFnName might be something like 'C:\\foo.js:myFunc'.
// If there is a single colon assume it's 'filename:funcname' not 'C:\\filename'. // If there is a single colon assume it's 'filename:funcname' not 'C:\\filename'.
const functionName = (tmp.length > 1 ? tmp.pop() : null) || hookName; const functionName = (tmp.length > 1 ? tmp.pop() : null) || hookName;
const moduleName = tmp.join(':') || part.plugin; const moduleName = tmp.join(':') || part.plugin;
const packageDir = path.dirname(defs.plugins[part.plugin].package.path); const packageDir = path.dirname(pluginDefs.getPlugins()[part.plugin!].package.path);
const fileName = path.join(packageDir, moduleName); const fileName = path.join(packageDir, moduleName!);
return `${fileName}:${functionName}`; return `${fileName}:${functionName}`;
}; };
exports.update = async () => { export const update = async () => {
const packages = await exports.getPackages(); const packages = await getPackages();
const parts = {}; // Key is full name. sortParts converts this into a topologically sorted array. const parts: MapArrayType<Part> = {}; // Key is full name. sortParts converts this into a topologically sorted array.
const plugins = {}; const plugins: MapArrayType<any> = {};
// Load plugin metadata ep.json // Load plugin metadata ep.json
await Promise.all(Object.keys(packages).map(async (pluginName) => { await Promise.all(Object.keys(packages).map(async (pluginName) => {
@ -110,23 +114,27 @@ exports.update = async () => {
})); }));
logger.info(`Loaded ${Object.keys(packages).length} plugins`); logger.info(`Loaded ${Object.keys(packages).length} plugins`);
defs.plugins = plugins; pluginDefs.setPlugins(plugins);
defs.parts = sortParts(parts); pluginDefs.setParts(sortParts(parts));
defs.hooks = pluginUtils.extractHooks(defs.parts, 'hooks', exports.pathNormalization); pluginDefs.setHooks(pluginUtils.extractHooks(pluginDefs.getParts(), 'hooks', pathNormalization))
defs.loaded = true; pluginDefs.setLoaded(true);
await Promise.all(Object.keys(defs.plugins).map(async (p) => {
await Promise.all(Object.keys(pluginDefs.getPlugins()).map(async (p) => {
const logger = log4js.getLogger(`plugin:${p}`); const logger = log4js.getLogger(`plugin:${p}`);
await hooks.aCallAll(`init_${p}`, {logger}); await hooks.aCallAll(`init_${p}`, {logger});
})); }));
}; };
exports.getPackages = async () => { import {linkInstaller} from "./installer";
const {linkInstaller} = require("./installer"); import {MapArrayType} from "../../../node/types/MapType";
import {IPluginInfoExtended} from "./IPluginInfoExtended";
export const getPackages = async () => {
const plugins = await linkInstaller.listPlugins(); const plugins = await linkInstaller.listPlugins();
const newDependencies = {}; const newDependencies:MapArrayType<IPluginInfoExtended> = {};
for (const plugin of plugins) { for (const plugin of plugins) {
if (!plugin.name.startsWith(exports.prefix)) { if (!plugin.name.startsWith(prefix)) {
continue; continue;
} }
plugin.path = plugin.realPath = plugin.location; plugin.path = plugin.realPath = plugin.location;
@ -134,17 +142,20 @@ exports.getPackages = async () => {
} }
newDependencies['ep_etherpad-lite'] = { newDependencies['ep_etherpad-lite'] = {
dependencies: {},
location: "",
mainFile: "",
name: 'ep_etherpad-lite', name: 'ep_etherpad-lite',
version: settings.getEpVersion(), version: settings.getEpVersion(),
path: path.join(settings.root, 'node_modules/ep_etherpad-lite'), path: path.join(settings.root, 'node_modules/ep_etherpad-lite'),
realPath: path.join(settings.root, 'src'), realPath: path.join(settings.root, 'src')
}; };
return newDependencies; return newDependencies;
}; };
const loadPlugin = async (packages, pluginName, plugins, parts) => { const loadPlugin = async (packages: MapArrayType<IPluginInfoExtended>, pluginName: string, plugins: MapArrayType<any>, parts: MapArrayType<Part>) => {
const pluginPath = path.resolve(packages[pluginName].path, 'ep.json'); const pluginPath = path.resolve(packages[pluginName].path!, 'ep.json');
try { try {
const data = await fs.readFile(pluginPath); const data = await fs.readFile(pluginPath);
try { try {
@ -156,16 +167,16 @@ const loadPlugin = async (packages, pluginName, plugins, parts) => {
part.full_name = `${pluginName}/${part.name}`; part.full_name = `${pluginName}/${part.name}`;
parts[part.full_name] = part; parts[part.full_name] = part;
} }
} catch (err) { } catch (err: any) {
logger.error(`Unable to parse plugin definition file ${pluginPath}: ${err.stack || err}`); logger.error(`Unable to parse plugin definition file ${pluginPath}: ${err.stack || err}`);
} }
} catch (err) { } catch (err: any) {
logger.error(`Unable to load plugin definition file ${pluginPath}: ${err.stack || err}`); logger.error(`Unable to load plugin definition file ${pluginPath}: ${err.stack || err}`);
} }
}; };
const partsToParentChildList = (parts) => { const partsToParentChildList = (parts: MapArrayType<Part>) => {
const res = []; const res:[string,string][] = [];
for (const name of Object.keys(parts)) { for (const name of Object.keys(parts)) {
for (const childName of parts[name].post || []) { for (const childName of parts[name].post || []) {
res.push([name, childName]); res.push([name, childName]);
@ -181,6 +192,6 @@ const partsToParentChildList = (parts) => {
}; };
// Used only in Node, so no need for _ // Used only in Node, so no need for _
const sortParts = (parts) => tsort(partsToParentChildList(parts)) const sortParts = (parts: MapArrayType<Part>) => new TSort(partsToParentChildList(parts)).getSorted()
.filter((name) => parts[name] !== undefined) .filter((name) => parts[name] !== undefined)
.map((name) => parts[name]); .map((name) => parts[name]);

View file

@ -1,15 +1,16 @@
'use strict'; 'use strict';
const defs = require('./plugin_defs'); import {Part, pluginDefs, PluginHook} from './plugin_defs';
import {MapArrayType} from "../../../node/types/MapType";
const disabledHookReasons = { const disabledHookReasons: MapArrayType<any> = {
hooks: { hooks: {
indexCustomInlineScripts: 'The hook makes it impossible to use a Content Security Policy ' + indexCustomInlineScripts: 'The hook makes it impossible to use a Content Security Policy ' +
'that prohibits inline code. Permitting inline code makes XSS vulnerabilities more likely', 'that prohibits inline code. Permitting inline code makes XSS vulnerabilities more likely',
}, },
}; };
const loadFn = (path, hookName, modules) => { const loadFn = (path: string, hookName: string, modules: Function) => {
let functionName; let functionName;
const parts = path.split(':'); const parts = path.split(':');
@ -28,6 +29,7 @@ const loadFn = (path, hookName, modules) => {
if (modules === undefined || !("get" in modules)) { if (modules === undefined || !("get" in modules)) {
fn = require(/* webpackIgnore: true */ path); fn = require(/* webpackIgnore: true */ path);
} else { } else {
// @ts-ignore
fn = modules.get(path); fn = modules.get(path);
} }
@ -39,9 +41,10 @@ const loadFn = (path, hookName, modules) => {
return fn; return fn;
}; };
const extractHooks = (parts, hookSetName, normalizer, modules) => { export const extractHooks = (parts: Part[], hookSetName: string, normalizer: Function|null, modules: Function) => {
const hooks = {}; const hooks: MapArrayType<PluginHook[]> = {};
for (const part of parts) { for (const part of parts) {
// @ts-ignore
for (const [hookName, regHookFnName] of Object.entries(part[hookSetName] || {})) { for (const [hookName, regHookFnName] of Object.entries(part[hookSetName] || {})) {
/* On the server side, you can't just /* On the server side, you can't just
* require("pluginname/whatever") if the plugin is installed as * require("pluginname/whatever") if the plugin is installed as
@ -64,6 +67,7 @@ const extractHooks = (parts, hookSetName, normalizer, modules) => {
} catch (err) { } catch (err) {
console.error(`Failed to load hook function "${hookFnName}" for plugin "${part.plugin}" ` + console.error(`Failed to load hook function "${hookFnName}" for plugin "${part.plugin}" ` +
`part "${part.name}" hook set "${hookSetName}" hook "${hookName}": ` + `part "${part.name}" hook set "${hookSetName}" hook "${hookName}": ` +
// @ts-ignore
`${err.stack || err}`); `${err.stack || err}`);
} }
if (hookFn) { if (hookFn) {
@ -80,7 +84,6 @@ const extractHooks = (parts, hookSetName, normalizer, modules) => {
return hooks; return hooks;
}; };
exports.extractHooks = extractHooks;
/* /*
* Returns an array containing the names of the installed client-side plugins * Returns an array containing the names of the installed client-side plugins
@ -94,8 +97,8 @@ exports.extractHooks = extractHooks;
* No plugins: [] * No plugins: []
* Some plugins: [ 'ep_adminpads', 'ep_add_buttons', 'ep_activepads' ] * Some plugins: [ 'ep_adminpads', 'ep_add_buttons', 'ep_activepads' ]
*/ */
exports.clientPluginNames = () => { export const clientPluginNames = () => {
const clientPluginNames = defs.parts const clientPluginNames = pluginDefs.getParts()
.filter((part) => Object.prototype.hasOwnProperty.call(part, 'client_hooks')) .filter((part) => Object.prototype.hasOwnProperty.call(part, 'client_hooks'))
.map((part) => `plugin-${part.plugin}`); .map((part) => `plugin-${part.plugin}`);
return [...new Set(clientPluginNames)]; return [...new Set(clientPluginNames)];

View file

@ -1,112 +0,0 @@
'use strict';
/**
* general topological sort
* from https://gist.github.com/1232505
* @author SHIN Suzuki (shinout310@gmail.com)
* @param Array<Array> edges : list of edges. each edge forms Array<ID,ID> e.g. [12 , 3]
*
* @returns Array : topological sorted list of IDs
**/
const tsort = (edges) => {
const nodes = {}; // hash: stringified id of the node => { id: id, afters: lisf of ids }
const sorted = []; // sorted list of IDs ( returned value )
const visited = {}; // hash: id of already visited node => true
const Node = function (id) {
this.id = id;
this.afters = [];
};
// 1. build data structures
edges.forEach((v) => {
const from = v[0]; const
to = v[1];
if (!nodes[from]) nodes[from] = new Node(from);
if (!nodes[to]) nodes[to] = new Node(to);
nodes[from].afters.push(to);
});
const visit = (idstr, ancestors) => {
const node = nodes[idstr];
const id = node.id;
// if already exists, do nothing
if (visited[idstr]) return;
if (!Array.isArray(ancestors)) ancestors = [];
ancestors.push(id);
visited[idstr] = true;
node.afters.forEach((afterID) => {
// if already in ancestors, a closed chain exists.
if (ancestors.indexOf(afterID) >= 0) throw new Error(`closed chain : ${afterID} is in ${id}`);
visit(afterID.toString(), ancestors.map((v) => v)); // recursive call
});
sorted.unshift(id);
};
// 2. topological sort
Object.keys(nodes).forEach(visit);
return sorted;
};
/**
* TEST
**/
const tsortTest = () => {
// example 1: success
let edges = [
[1, 2],
[1, 3],
[2, 4],
[3, 4],
];
let sorted = tsort(edges);
// example 2: failure ( A > B > C > A )
edges = [
['A', 'B'],
['B', 'C'],
['C', 'A'],
];
try {
sorted = tsort(edges);
console.log('succeeded', sorted);
} catch (e) {
console.log(e.message);
}
// example 3: generate random edges
const max = 100;
const iteration = 30;
const randomInt = (max) => Math.floor(Math.random() * max) + 1;
edges = (() => {
const ret = [];
let i = 0;
while (i++ < iteration) ret.push([randomInt(max), randomInt(max)]);
return ret;
})();
try {
sorted = tsort(edges);
console.log('succeeded', sorted);
} catch (e) {
console.log('failed', e.message);
}
};
// for node.js
if (typeof exports === 'object' && exports === this) {
module.exports = tsort;
if (process.argv[1] === __filename) tsortTest();
}

View file

@ -0,0 +1,74 @@
'use strict';
import {MapArrayType} from "../../../node/types/MapType";
/**
* general topological sort
* from https://gist.github.com/1232505
* @author SHIN Suzuki (shinout310@gmail.com)
* @param Array<Array> edges : list of edges. each edge forms Array<ID,ID> e.g. [12 , 3]
*
* @returns Array : topological sorted list of IDs
**/
export class TSort {
private nodes: MapArrayType<Node> = {}; // hash: stringified id of the node => { id: id, afters: lisf of ids }
private sorted: (number|string)[] = []; // sorted list of IDs ( returned value )
private visited: MapArrayType<boolean> = {}; // hash: id of already visited node => true
constructor(edges: (number|string)[][]) {
// 1. build data structures
edges.forEach((v) => {
const from = v[0]; const
to = v[1];
if (!this.nodes[from]) {
this.nodes[from] = new Node(from);
}
if (!this.nodes[to]) {
this.nodes[to] = new Node(to);
}
this.nodes[from].afters.push(to);
});
// 2. topological sort
for (const key in this.nodes) {
this.visit(key, []);
}
}
visit = (idstr: string, ancestors:(number|string)[]) => {
const node = this.nodes[idstr];
const id = node.id;
// if already exists, do nothing
if (this.visited[idstr]) return;
if (!Array.isArray(ancestors)) ancestors = [];
ancestors.push(id);
this.visited[idstr] = true;
node.afters.forEach((afterID) => {
// if already in ancestors, a closed chain exists.
if (ancestors.indexOf(afterID) >= 0) throw new Error(`closed chain : ${afterID} is in ${id}`);
this.visit(afterID.toString(), ancestors.map((v) => v)); // recursive call
});
this.sorted.unshift(id);
}
getSorted = ()=>{
return this.sorted
}
}
class Node {
id: number|string
afters: (number|string)[]
constructor(id: number|string) {
this.id = id
this.afters = []
}
}

View file

@ -32,8 +32,7 @@ import connect from './socketio'
import html10n from '../js/vendors/html10n' import html10n from '../js/vendors/html10n'
import {Socket} from "socket.io"; import {Socket} from "socket.io";
import {ClientVarData, ClientVarMessage, ClientVarPayload, SocketIOMessage} from "./types/SocketIOMessage"; import {ClientVarData, ClientVarMessage, ClientVarPayload, SocketIOMessage} from "./types/SocketIOMessage";
import {Func} from "mocha"; import {Revision} from "./broadcast_revisions";
export type ChangeSetLoader = { export type ChangeSetLoader = {
handleMessageFromServer(msg: ClientVarMessage): void handleMessageFromServer(msg: ClientVarMessage): void
} }
@ -111,13 +110,14 @@ const sendSocketMsg = (type: string, data: Object) => {
const fireWhenAllScriptsAreLoaded: Function[] = []; const fireWhenAllScriptsAreLoaded: Function[] = [];
const handleClientVars = (message: ClientVarData) => { const handleClientVars = async (message: ClientVarData) => {
// save the client Vars // save the client Vars
window.clientVars = message.data; window.clientVars = message.data;
if (window.clientVars.sessionRefreshInterval) { if (window.clientVars.sessionRefreshInterval) {
const ping = const ping =
() => $.ajax('../../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {}); () => $.ajax('../../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {
});
setInterval(ping, window.clientVars.sessionRefreshInterval); setInterval(ping, window.clientVars.sessionRefreshInterval);
} }
@ -133,7 +133,7 @@ const handleClientVars = (message: ClientVarData) => {
BroadcastSlider = require('./broadcast_slider') BroadcastSlider = require('./broadcast_slider')
.loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded); .loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded);
require('./broadcast_revisions').loadBroadcastRevisionsJS(); await import('./broadcast_revisions')
changesetLoader = require('./broadcast') changesetLoader = require('./broadcast')
.loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider); .loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider);

View file

@ -0,0 +1,7 @@
export type PadRevision = {
revNum: number;
savedById: string;
label: string;
timestamp: number;
id: string;
}

View file

@ -2,6 +2,9 @@ import {MapArrayType} from "../../../node/types/MapType";
import {AText} from "./AText"; import {AText} from "./AText";
import AttributePool from "../AttributePool"; import AttributePool from "../AttributePool";
import attributePool from "../AttributePool"; import attributePool from "../AttributePool";
import ChatMessage from "../ChatMessage";
import {PadRevision} from "./PadRevision";
import {MappedPlugin} from "../pluginfw/plugin_defs";
export type SocketIOMessage = { export type SocketIOMessage = {
type: string type: string
@ -11,16 +14,25 @@ export type SocketIOMessage = {
export type HistoricalAuthorData = MapArrayType<{ export type HistoricalAuthorData = MapArrayType<{
name: string; name: string;
colorId: number; colorId: number;
userId: string userId?: string
}> }>
export type ServerVar = { export type ServerVar = {
rev: number rev: number
historicalAuthorData: HistoricalAuthorData, clientIp: string
initialAttributedText: string, padId: string
apool: AttributePool historicalAuthorData?: HistoricalAuthorData,
initialAttributedText: {
attribs: string
text: string
},
apool: AttributePoolWire
time: number
} }
export type AttributePoolWire = {numToAttrib: {[p: number]: [string, string]}, nextNum: number}
export type UserInfo = { export type UserInfo = {
userId: string userId: string
colorId: number, colorId: number,
@ -31,18 +43,18 @@ export type ClientVarPayload = {
readOnlyId: string readOnlyId: string
automaticReconnectionTimeout: number automaticReconnectionTimeout: number
sessionRefreshInterval: number, sessionRefreshInterval: number,
historicalAuthorData: HistoricalAuthorData, atext?: AText,
atext: AText, apool?: AttributePool,
apool: AttributePool, userName?: string,
noColors: boolean,
userName: string,
userColor: number, userColor: number,
hideChat: boolean, hideChat?: boolean,
padOptions: PadOption, padOptions: PadOption,
padId: string, padId: string,
clientIp: string, clientIp: string,
colorPalette: MapArrayType<number>, colorPalette: string[],
accountPrivs: MapArrayType<string>, accountPrivs: {
maxRevisions: number,
},
collab_client_vars: ServerVar, collab_client_vars: ServerVar,
chatHead: number, chatHead: number,
readonly: boolean, readonly: boolean,
@ -54,6 +66,29 @@ export type ClientVarPayload = {
skinName: string skinName: string
skinVariants: string, skinVariants: string,
exportAvailable: string exportAvailable: string
savedRevisions: PadRevision[],
initialRevisionList: number[],
padShortcutEnabled: MapArrayType<boolean>,
initialTitle: string,
opts: {}
numConnectedUsers: number
abiwordAvailable: string
sofficeAvailable: string
plugins: {
plugins: MapArrayType<any>
parts: MappedPlugin[]
}
indentationOnNewLine: boolean
scrollWhenFocusLineIsOutOfViewport : {
percentage: {
editionAboveViewport: number,
editionBelowViewport: number
}
duration: number
scrollWhenCaretIsInTheLastLineOfViewport: boolean
percentageToScrollWhenUserPressesArrowUp: number
}
initialChangesets: []
} }
export type ClientVarData = { export type ClientVarData = {
@ -105,7 +140,9 @@ export type ClientMessageMessage = {
export type ChatMessageMessage = { export type ChatMessageMessage = {
type: 'CHAT_MESSAGE' type: 'CHAT_MESSAGE'
message: string data: {
message: ChatMessage
}
} }
export type ChatMessageMessages = { export type ChatMessageMessages = {
@ -122,7 +159,18 @@ export type ClientUserChangesMessage = {
export type ClientSendMessages = ClientUserChangesMessage | ClientSendUserInfoUpdate| ClientMessageMessage | GetChatMessageMessage |ClientSuggestUserName | NewRevisionListMessage | RevisionLabel | PadOptionsMessage| ClientSaveRevisionMessage export type ClientSendMessages = ClientUserChangesMessage |ClientReadyMessage| ClientSendUserInfoUpdate|ChatMessageMessage| ClientMessageMessage | GetChatMessageMessage |ClientSuggestUserName | NewRevisionListMessage | RevisionLabel | PadOptionsMessage| ClientSaveRevisionMessage
export type ClientReadyMessage = {
type: 'CLIENT_READY',
component: string,
padId: string,
sessionID: string,
token: string,
userInfo: UserInfo,
reconnect?: boolean
client_rev?: number
}
export type ClientSaveRevisionMessage = { export type ClientSaveRevisionMessage = {
type: 'SAVE_REVISION' type: 'SAVE_REVISION'
@ -191,18 +239,44 @@ export type ClientDisconnectedMessage = {
type: "disconnected" type: "disconnected"
disconnected: boolean disconnected: boolean
} }
export type UserChanges = {
data: ClientUserChangesMessage
}
export type UserSuggestUserName = {
data: {
payload: ClientSuggestUserName
}
}
export type ChangesetRequestMessage = {
type: 'CHANGESET_REQ'
data: {
granularity: number
start: number
requestID: string
}
}
export type CollabroomMessage = {
type: 'COLLABROOM'
data: ClientSendUserInfoUpdate | ClientUserChangesMessage | ChatMessageMessage | GetChatMessageMessage | ClientSaveRevisionMessage | ClientMessageMessage
}
export type ClientVarMessage = { export type ClientVarMessage = {
type: 'CHANGESET_REQ'| 'COLLABROOM'| 'CUSTOM' type: 'CUSTOM'
data: data: ClientVarData
| ClientNewChanges } | ClientVarData | ClientDisconnectedMessage | ClientReadyMessage| ChangesetRequestMessage | CollabroomMessage
| ClientAcceptCommitMessage
|UserNewInfoMessage export type ClientCustomMessage = {
| UserLeaveMessage type: 'CUSTOM',
|ClientMessageMessage action: string,
|ChatMessageMessage payload: any
|ChatMessageMessages
|ClientConnectMessage, }
} | ClientVarData | ClientDisconnectedMessage
export type SocketClientReadyMessage = { export type SocketClientReadyMessage = {
type: string type: string

View file

@ -1,5 +1,6 @@
import {ClientVarData, ClientVarPayload} from "./SocketIOMessage"; import {ClientVarData, ClientVarPayload} from "./SocketIOMessage";
import {Pad} from "../pad"; import {Pad} from "../pad";
import {Revision} from "../broadcast_revisions";
declare global { declare global {
interface Window { interface Window {
@ -7,6 +8,7 @@ declare global {
$: any, $: any,
customStart?:any, customStart?:any,
ajlog: string ajlog: string
revisionInfo: Record<number|string, number|Revision>
} }
let pad: Pad let pad: Pad
} }

View file

@ -0,0 +1,74 @@
import {describe, it} from 'vitest'
import {TSort} from "../../../static/js/pluginfw/tsort";
import {expect} from "@playwright/test";
describe('tsort', () => {
it('should work', () => {
let edges: (number|string)[][] = [
[1, 2],
[1, 3],
[2, 4],
[3, 4],
];
let sorted = new TSort(edges).getSorted();
expect(sorted).toEqual([1, 3, 2, 4]);
// example 3: generate random edges
const max = 100;
const iteration = 30;
const randomInt = (max: number) => Math.floor(Math.random() * max) + 1;
edges = (() => {
const ret = [];
let i = 0;
while (i++ < iteration) ret.push([randomInt(max), randomInt(max)]);
return ret;
})();
try {
sorted = new TSort(edges).getSorted();
console.log('succeeded', sorted);
} catch (e) {
// @ts-ignore
console.log('failed', e.message);
}
})
it('fails with closed chain', ()=> {
// example 2: failure ( A > B > C > A )
let edges = [
['A', 'B'],
['B', 'C'],
['C', 'A'],
];
expect(() => new TSort(edges)).toThrowError('closed chain : A is in C');
})
it('fails with closed chain 2', ()=> {
// example 1: failure ( 1 > 2 > 3 > 1 )
let edges = [
[1, 2],
[2, 3],
[3, 1],
];
expect(() => new TSort(edges)).toThrowError('closed chain : 1 is in 3');
})
it('works with 6 nodes', ()=> {
let edges = [
[1, 2],
[1, 3],
[2, 4],
[3, 4],
[4, 5],
[4, 6],
];
let sorted = new TSort(edges).getSorted();
expect(sorted).toEqual([1, 3, 2, 4, 6, 5]);
})
})