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(
`globalAuthor:${author}`, ['colorId'], colorId); // @ts-ignore
`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)) {

File diff suppressed because it is too large Load diff

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,7 +262,8 @@ module.exports = {
}, },
registerButton(buttonName: string, buttonInfo: any) { registerButton(buttonName: string, buttonInfo: any) {
this.availableButtons[buttonName] = buttonInfo; // @ts-ignore
this.availableButtons[buttonName] = buttonInfo;
}, },
button: (attributes: AttributeObj) => new Button(attributes), button: (attributes: AttributeObj) => new Button(attributes),
@ -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 = {}; class RevisionInfo {
revisionInfo.addChangeset = function (fromIndex, toIndex, changeset, backChangeset, timeDelta) { private revisionInfo: Record<number|string, number|Revision> = {};
const startRevision = this[fromIndex] || this.createNew(fromIndex);
const endRevision = this[toIndex] || this.createNew(toIndex); 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); startRevision.addChangeset(toIndex, changeset, timeDelta);
endRevision.addChangeset(fromIndex, backChangeset, -1 * timeDelta); endRevision.addChangeset(fromIndex, backChangeset, -1 * timeDelta);
}; }
revisionInfo.latest = clientVars.collab_client_vars.rev || -1; createNew = (index: number)=> {
this.revisionInfo![index] = new Revision(index);
revisionInfo.createNew = function (index) { if (index > (this.revisionInfo.latest as number)) {
this[index] = new Revision(index); this.revisionInfo.latest = index;
if (index > this.latest) {
this.latest = index;
} }
return this[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)) {
@ -74,21 +88,21 @@ const loadBroadcastRevisionsJS = () => {
const oldRev = elem.rev; const oldRev = elem.rev;
for (let i = reverse ? elem.changesets.length - 1 : 0; for (let i = reverse ? elem.changesets.length - 1 : 0;
reverse ? i >= 0 : i < elem.changesets.length; reverse ? i >= 0 : i < elem.changesets.length;
i += reverse ? -1 : 1) { i += reverse ? -1 : 1) {
if (((elem.changesets[i].deltaRev < 0) && !reverse) || if (((elem.changesets[i].deltaRev < 0) && !reverse) ||
((elem.changesets[i].deltaRev > 0) && reverse)) { ((elem.changesets[i].deltaRev > 0) && reverse)) {
couldNotContinue = true; couldNotContinue = true;
break; break;
} }
if (((elem.rev + elem.changesets[i].deltaRev <= toIndex) && !reverse) || if (((elem.rev + elem.changesets[i].deltaRev <= toIndex) && !reverse) ||
((elem.rev + elem.changesets[i].deltaRev >= toIndex) && reverse)) { ((elem.rev + elem.changesets[i].deltaRev >= toIndex) && reverse)) {
const topush = elem.changesets[i]; const topush = elem.changesets[i];
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,7 +67,8 @@ 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}": ` +
`${err.stack || err}`); // @ts-ignore
`${err.stack || err}`);
} }
if (hookFn) { if (hookFn) {
if (hooks[hookName] == null) hooks[hookName] = []; if (hooks[hookName] == null) hooks[hookName] = [];
@ -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,19 +110,20 @@ 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);
} }
if(window.clientVars.mode === "development") { if (window.clientVars.mode === "development") {
console.warn('Enabling development mode with live update') console.warn('Enabling development mode with live update')
socket.on('liveupdate', ()=>{ socket.on('liveupdate', () => {
console.log('Doing live reload') console.log('Doing live reload')
location.reload() location.reload()
}) })
@ -131,11 +131,11 @@ const handleClientVars = (message: ClientVarData) => {
// load all script that doesn't work without the clientVars // load all script that doesn't work without the clientVars
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);
// initialize export ui // initialize export ui
require('./pad_impexp').padimpexp.init(); require('./pad_impexp').padimpexp.init();

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]);
})
})