Merge branch 'develop'

This commit is contained in:
John McLear 2021-11-20 15:20:37 +00:00
commit 868c6852de
116 changed files with 6603 additions and 6501 deletions

View file

@ -1,3 +1,5 @@
IMPORTANT: Please disable plugins prior to posting a bug report. If you have a problem with a plugin please post on the plugin repository. Thanks!
--- ---
name: Bug report name: Bug report
about: Create a report to help us improve about: Create a report to help us improve
@ -28,6 +30,7 @@ If applicable, add screenshots to help explain your problem.
- OS: [e.g., Ubuntu 20.04] - OS: [e.g., Ubuntu 20.04]
- Node.js version (`node --version`): - Node.js version (`node --version`):
- npm version (`npm --version`): - npm version (`npm --version`):
- Is the server free of plugins:
**Desktop (please complete the following information):** **Desktop (please complete the following information):**
- OS: [e.g. iOS] - OS: [e.g. iOS]

View file

@ -1,29 +1,13 @@
<!-- <!--
Some key notes before you open a PR: 1. If you haven't already, please read https://github.com/ether/etherpad-lite/blob/master/CONTRIBUTING.md#pull-requests .
2. Run all the tests, both front-end and back-end. (see https://github.com/ether/etherpad-lite/blob/master/CONTRIBUTING.md#testing)
3. Keep business logic and validation on the server-side.
4. Update documentation.
5. Write `fixes #XXXX` in your comment to auto-close an issue.
1. Select which branch should this PR be merged in? By default, you should always merge to the develop branch. If you're making a big change, please explain what problem it solves:
2. PR name follows [convention](http://karma-runner.github.io/4.0/dev/git-commit-msg.html) - Explain the purpose of the change. When adding a way to do X, explain why it is important to be able to do X.
3. All tests pass locally, UI and Unit tests - Show the current vs desired behavior with screenshots/GIFs.
4. All business logic and validations must be on the server-side
5. Update necessary Documentation
6. Put `closes #XXXX` in your comment to auto-close the issue that your PR fixes
Also, if you're new here
- Contribution Guide => https://github.com/ether/etherpad-lite/blob/master/CONTRIBUTING.md
--> -->
> Please provide enough information so that others can review your pull request:
<!-- You can skip this if you're fixing a typo or updating existing documentation -->
> Explain the **details** for making this change. What existing problem does the pull request solve?
<!-- Example: When "Adding a function to do X", explain why it is necessary to have a way to do X. -->
> Screenshots/GIFs
<!-- Add images/recordings to better visualize the change: expected/current behviour -->

View file

@ -116,9 +116,7 @@ jobs:
node-version: 12 node-version: 12
- name: Install all dependencies and symlink for ep_etherpad-lite - name: Install all dependencies and symlink for ep_etherpad-lite
run: | run: src/bin/installOnWindows.bat
cd src
npm ci --no-optional
- name: Fix up the settings.json - name: Fix up the settings.json
run: | run: |
@ -172,9 +170,7 @@ jobs:
# if npm correctly hoists the dependencies, the hoisting seems to confuse # if npm correctly hoists the dependencies, the hoisting seems to confuse
# tools such as `npm outdated`, `npm update`, and some ESLint rules. # tools such as `npm outdated`, `npm update`, and some ESLint rules.
- name: Install all dependencies and symlink for ep_etherpad-lite - name: Install all dependencies and symlink for ep_etherpad-lite
run: | run: src/bin/installOnWindows.bat
cd src
npm ci --no-optional
- name: Fix up the settings.json - name: Fix up the settings.json
run: | run: |

View file

@ -57,6 +57,9 @@ jobs:
- name: Write custom settings.json that enables the Admin UI tests - name: Write custom settings.json that enables the Admin UI tests
run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme\",\"is_admin\":true}}/' settings.json" run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme\",\"is_admin\":true}}/' settings.json"
- name: increase maxHttpBufferSize
run: "sed -i 's/\"maxHttpBufferSize\": 10000/\"maxHttpBufferSize\": 100000/' settings.json"
- name: Remove standard frontend test files, so only admin tests are run - name: Remove standard frontend test files, so only admin tests are run
run: mv src/tests/frontend/specs/* /tmp && mv /tmp/admin*.js src/tests/frontend/specs run: mv src/tests/frontend/specs/* /tmp && mv /tmp/admin*.js src/tests/frontend/specs

1
.gitignore vendored
View file

@ -9,7 +9,6 @@ var/dirty.db
*.patch *.patch
npm-debug.log npm-debug.log
*.DS_Store *.DS_Store
.ep_initialized
*.crt *.crt
*.key *.key
credentials.json credentials.json

View file

@ -1,3 +1,79 @@
# 1.8.15
### Security fixes
* Fixed leak of the writable pad ID when exporting from the pad's read-only ID.
This only matters if you treat the writeable pad IDs as secret (e.g., you are
not using [ep_padlist2](https://www.npmjs.com/package/ep_padlist2)) and you
share the pad's read-only ID with untrusted users. Instead of treating
writeable pad IDs as secret, you are encouraged to take advantage of
Etherpad's authentication and authorization mechanisms (e.g., use
[ep_openid_connect](https://www.npmjs.com/package/ep_openid_connect) with
[ep_readonly_guest](https://www.npmjs.com/package/ep_readonly_guest), or write
your own
[authentication](https://etherpad.org/doc/v1.8.14/#index_authenticate) and
[authorization](https://etherpad.org/doc/v1.8.14/#index_authorize) plugins).
### Compatibility changes
* The `logconfig` setting is deprecated.
* For plugin authors:
* Etherpad now uses [jsdom](https://github.com/jsdom/jsdom) instead of
[cheerio](https://cheerio.js.org/) for processing HTML imports. There are
two consequences of this change:
* `require('ep_etherpad-lite/node_modules/cheerio')` no longer works. To
fix, your plugin should directly depend on `cheerio` and do
`require('cheerio')`.
* The `node` context argument passed to the `collectContentImage` hook is
now an
[`HTMLImageElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement)
object rather than a Cheerio Node-like object, so the API is slightly
different. See
[citizenos/ep_image_upload#49](https://github.com/citizenos/ep_image_upload/pull/49)
for an example fix.
* The `clientReady` server-side hook is deprecated; use the new `userJoin`
hook instead.
* The `init_<pluginName>` server-side hooks are now run every time Etherpad
starts up, not just the first time after the named plugin is installed.
* The `userLeave` server-side hook's context properties have changed:
* `auth`: Deprecated.
* `author`: Deprecated; use the new `authorId` property instead.
* `readonly`: Deprecated; use the new `readOnly` property instead.
* `rev`: Deprecated.
* Changes to the `src/static/js/Changeset.js` library:
* `opIterator()`: The unused start index parameter has been removed, as has
the unused `lastIndex()` method on the returned object.
* `smartOpAssembler()`: The returned object's `appendOpWithText()` method is
deprecated without a replacement available to plugins (if you need one,
let us know and we can make the private `opsFromText()` function public).
* Several functions that should have never been public are no longer
exported: `applyZip()`, `assert()`, `clearOp()`, `cloneOp()`, `copyOp()`,
`error()`, `followAttributes()`, `opString()`, `stringOp()`,
`textLinesMutator()`, `toBaseTen()`, `toSplices()`.
### Notable enhancements
* Simplified pad reload after importing an `.etherpad` file.
* For plugin authors:
* `clientVars` was added to the context for the `postAceInit` client-side
hook. Plugins should use this instead of the `clientVars` global variable.
* New `userJoin` server-side hook.
* The `userLeave` server-side hook has a new `socket` context property.
* The `helper.aNewPad()` function (accessible to client-side tests) now
accepts hook functions to inject when opening a pad. This can be used to
test any new client-side hooks your plugin provides.
* Chat improvements:
* The `chatNewMessage` client-side hook context has new properties:
* `message`: Provides access to the raw message object so that plugins can
see the original unprocessed message text and any added metadata.
* `rendered`: Allows plugins to completely override how the message is
rendered in the UI.
* New `chatSendMessage` client-side hook that enables plugins to process the
text before sending it to the server or augment the message object with
custom metadata.
* New `chatNewMessage` server-side hook to process new chat messages before
they are saved to the database and relayed to users.
# 1.8.14 # 1.8.14
### Security fixes ### Security fixes

View file

@ -15,9 +15,17 @@
number of the issue that is being fixed, in the form: Fixes #someIssueNumber number of the issue that is being fixed, in the form: Fixes #someIssueNumber
``` ```
* if the PR is a **bug fix**: * if the PR is a **bug fix**:
* the first commit in the series must be a test that shows the failure * The commit that fixes the bug should **include a regression test** that
* subsequent commits will fix the bug and make the test pass would fail if the bug fix was reverted. Adding the regression test in the
* the final commit message should include the text `Fixes: #xxx` to link it to its bug report same commit as the bug fix makes it easier for a reviewer to verify that the
test is appropriate for the bug fix.
* If there is a bug report, **the pull request description should include the
text "`Fixes #xxx`"** so that the bug report is auto-closed when the PR is
merged. It is less useful to say the same thing in a commit message because
GitHub will spam the bug report every time the commit is rebased, and
because a bug number alone becomes meaningless in forks. (A full URL would
be better, but ideally each commit is readable on its own without the need
to examine an external reference to understand motivation or context.)
* think about stability: code has to be backwards compatible as much as possible. Always **assume your code will be run with an older version of the DB/config file** * think about stability: code has to be backwards compatible as much as possible. Always **assume your code will be run with an older version of the DB/config file**
* if you want to remove a feature, **deprecate it instead**: * if you want to remove a feature, **deprecate it instead**:
* write an issue with your deprecation plan * write an issue with your deprecation plan

View file

@ -63,6 +63,7 @@ RUN export DEBIAN_FRONTEND=noninteractive; \
apt-get -qq --no-install-recommends install \ apt-get -qq --no-install-recommends install \
ca-certificates \ ca-certificates \
git \ git \
curl \
${INSTALL_ABIWORD:+abiword} \ ${INSTALL_ABIWORD:+abiword} \
${INSTALL_SOFFICE:+libreoffice} \ ${INSTALL_SOFFICE:+libreoffice} \
&& \ && \
@ -94,5 +95,7 @@ COPY --chown=etherpad:etherpad ./settings.json.docker "${EP_DIR}"/settings.json
# Fix group permissions # Fix group permissions
RUN chmod -R g=u . RUN chmod -R g=u .
HEALTHCHECK --interval=20s --timeout=3s CMD curl -f http://localhost:9001 || exit 1
EXPOSE 9001 EXPOSE 9001
CMD ["node", "src/node/server.js"] CMD ["node", "src/node/server.js"]

View file

@ -1,156 +1,44 @@
# Changeset Library # Changeset Library
``` The [changeset
"Z:z>1|2=m=b*0|1+1$\n" library](https://github.com/ether/etherpad-lite/blob/develop/src/static/js/Changeset.js)
provides tools to create, read, and apply changesets.
## Changeset
```javascript
const Changeset = require('ep_etherpad-lite/static/js/Changeset');
``` ```
This is a Changeset. It's just a string and it's very difficult to read in this form. But the Changeset Library gives us some tools to read it. A changeset describes the difference between two revisions of a document. When a
user edits a pad, the browser generates and sends a changeset to the server,
which relays it to the other users and saves a copy (so that every past revision
is accessible).
A changeset describes the diff between two revisions of the document. The Browser sends changesets to the server and the server sends them to the clients to update them. These Changesets also get saved into the history of a pad. This allows us to go back to every revision from the past. A transmitted changeset looks like this:
## Changeset.unpack(changeset)
* `changeset` {String}
This function returns an object representation of the changeset, similar to this:
``` ```
{ oldLen: 35, newLen: 36, ops: '|2=m=b*0|1+1', charBank: '\n' } 'Z:z>1|2=m=b*0|1+1$\n'
``` ```
* `oldLen` {Number} the original length of the document. ## Attribute Pool
* `newLen` {Number} the length of the document after the changeset is applied.
* `ops` {String} the actual changes, introduced by this changeset.
* `charBank` {String} All characters that are added by this changeset.
## Changeset.opIterator(ops) ```javascript
const AttributePool = require('ep_etherpad-lite/static/js/AttributePool');
* `ops` {String} The operators, returned by `Changeset.unpack()`
Returns an operator iterator. This iterator allows us to iterate over all operators that are in the changeset.
You can iterate with an opIterator using its `next()` and `hasNext()` methods. Next returns the `next()` operator object and `hasNext()` indicates, whether there are any operators left.
## The Operator object
There are 3 types of operators: `+`,`-` and `=`. These operators describe different changes to the document, beginning with the first character of the document. A `=` operator doesn't change the text, but it may add or remove text attributes. A `-` operator removes text. And a `+` Operator adds text and optionally adds some attributes to it.
* `opcode` {String} the operator type
* `chars` {Number} the length of the text changed by this operator.
* `lines` {Number} the number of lines changed by this operator.
* `attribs` {attribs} attributes set on this text.
### Example
```
{ opcode: '+',
chars: 1,
lines: 1,
attribs: '*0' }
``` ```
## APool Changesets do not include any attribute keyvalue pairs. Instead, they use
numeric identifiers that reference attributes kept in an [attribute
pool](https://github.com/ether/etherpad-lite/blob/develop/src/static/js/AttributePool.js).
This attribute interning reduces the transmission overhead of attributes that
are used many times.
``` There is one attribute pool per pad, and it includes every current and
> var AttributePoolFactory = require("./utils/AttributePoolFactory"); historical attribute used in the pad.
> var apool = AttributePoolFactory.createAttributePool();
> console.log(apool)
{ numToAttrib: {},
attribToNum: {},
nextNum: 0,
putAttrib: [Function],
getAttrib: [Function],
getAttribKey: [Function],
getAttribValue: [Function],
eachAttrib: [Function],
toJsonable: [Function],
fromJsonable: [Function] }
```
This creates an empty apool. An apool saves which attributes were used during the history of a pad. There is one apool for each pad. It only saves the attributes that were really used, it doesn't save unused attributes. Let's fill this apool with some values ## Further Reading
```
> apool.fromJsonable({"numToAttrib":{"0":["author","a.kVnWeomPADAT2pn9"],"1":["bold","true"],"2":["italic","true"]},"nextNum":3});
> console.log(apool)
{ numToAttrib:
{ '0': [ 'author', 'a.kVnWeomPADAT2pn9' ],
'1': [ 'bold', 'true' ],
'2': [ 'italic', 'true' ] },
attribToNum:
{ 'author,a.kVnWeomPADAT2pn9': 0,
'bold,true': 1,
'italic,true': 2 },
nextNum: 3,
putAttrib: [Function],
getAttrib: [Function],
getAttribKey: [Function],
getAttribValue: [Function],
eachAttrib: [Function],
toJsonable: [Function],
fromJsonable: [Function] }
```
We used the fromJsonable function to fill the empty apool with values. the fromJsonable and toJsonable functions are used to serialize and deserialize an apool. You can see that it stores the relation between numbers and attributes. So for example the attribute 1 is the attribute bold and vise versa. An attribute is always a key value pair. For stuff like bold and italic it's just 'italic':'true'. For authors it's author:$AUTHORID. So a character can be bold and italic. But it can't belong to multiple authors
```
> apool.getAttrib(1)
[ 'bold', 'true' ]
```
Simple example of how to get the key value pair for the attribute 1
## AText
```
> var atext = {"text":"bold text\nitalic text\nnormal text\n\n","attribs":"*0*1+9*0|1+1*0*1*2+b|1+1*0+b|2+2"};
> console.log(atext)
{ text: 'bold text\nitalic text\nnormal text\n\n',
attribs: '*0*1+9*0|1+1*0*1*2+b|1+1*0+b|2+2' }
```
This is an atext. An atext has two parts: text and attribs. The text is just the text of the pad as a string. We will look closer at the attribs at the next steps
```
> var opiterator = Changeset.opIterator(atext.attribs)
> console.log(opiterator)
{ next: [Function: next],
hasNext: [Function: hasNext],
lastIndex: [Function: lastIndex] }
> opiterator.next()
{ opcode: '+',
chars: 9,
lines: 0,
attribs: '*0*1' }
> opiterator.next()
{ opcode: '+',
chars: 1,
lines: 1,
attribs: '*0' }
> opiterator.next()
{ opcode: '+',
chars: 11,
lines: 0,
attribs: '*0*1*2' }
> opiterator.next()
{ opcode: '+',
chars: 1,
lines: 1,
attribs: '' }
> opiterator.next()
{ opcode: '+',
chars: 11,
lines: 0,
attribs: '*0' }
> opiterator.next()
{ opcode: '+',
chars: 2,
lines: 2,
attribs: '' }
```
The attribs are again a bunch of operators like .ops in the changeset was. But these operators are only + operators. They describe which part of the text has which attributes
## Resources / further reading
Detailed information about the changesets & Easysync protocol: Detailed information about the changesets & Easysync protocol:
* Easysync Protocol - [/doc/easysync/easysync-notes.pdf](https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-notes.pdf) * [Easysync Protocol](https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-notes.pdf)
* Etherpad and EasySync Technical Manual - [/doc/easysync/easysync-full-description.pdf](https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-full-description.pdf) * [Etherpad and EasySync Technical Manual](https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-full-description.pdf)

View file

@ -5,7 +5,7 @@ src/static/js/pad_editbar.js
## disable() ## disable()
## toggleDropDown(dropdown, callback) ## toggleDropDown(dropdown)
Shows the dropdown `div.popup` whose `id` equals `dropdown`. Shows the dropdown `div.popup` whose `id` equals `dropdown`.
## registerCommand(cmd, callback) ## registerCommand(cmd, callback)

View file

@ -18,7 +18,6 @@ Returns the `rep` object.
## editorInfo.ace_setOnKeyDown(?) ## editorInfo.ace_setOnKeyDown(?)
## editorInfo.ace_setNotifyDirty(?) ## editorInfo.ace_setNotifyDirty(?)
## editorInfo.ace_dispose(?) ## editorInfo.ace_dispose(?)
## editorInfo.ace_getFormattedCode(?)
## editorInfo.ace_setEditable(bool) ## editorInfo.ace_setEditable(bool)
## editorInfo.ace_execCommand(?) ## editorInfo.ace_execCommand(?)
## editorInfo.ace_callWithAce(fn, callStack, normalize) ## editorInfo.ace_callWithAce(fn, callStack, normalize)
@ -30,9 +29,6 @@ Returns the `rep` object.
## editorInfo.ace_applyPreparedChangesetToBase() ## editorInfo.ace_applyPreparedChangesetToBase()
## editorInfo.ace_setUserChangeNotificationCallback(f) ## editorInfo.ace_setUserChangeNotificationCallback(f)
## editorInfo.ace_setAuthorInfo(author, info) ## editorInfo.ace_setAuthorInfo(author, info)
## editorInfo.ace_setAuthorSelectionRange(author, start, end)
## editorInfo.ace_getUnhandledErrors()
## editorInfo.ace_getDebugProperty(prop)
## editorInfo.ace_fastIncorp(?) ## editorInfo.ace_fastIncorp(?)
## editorInfo.ace_isCaret(?) ## editorInfo.ace_isCaret(?)
## editorInfo.ace_getLineAndCharForPoint(?) ## editorInfo.ace_getLineAndCharForPoint(?)

View file

@ -229,7 +229,10 @@ Called from: src/static/js/pad.js
Things in context: Things in context:
1. ace - the ace object that is applied to this editor. 1. ace - the ace object that is applied to this editor.
2. pad - the pad object of the current pad. 2. clientVars - Object containing client-side configuration such as author ID
and plugin settings. Your plugin can manipulate this object via the
`clientVars` server-side hook.
3. pad - the pad object of the current pad.
## postToolbarInit ## postToolbarInit
@ -276,29 +279,53 @@ Things in context:
This hook is called on the client side whenever a user joins or changes. This This hook is called on the client side whenever a user joins or changes. This
can be used to create notifications or an alternate user list. can be used to create notifications or an alternate user list.
## chatNewMessage ## `chatNewMessage`
Called from: src/static/js/chat.js Called from: `src/static/js/chat.js`
Things in context: This hook runs on the client side whenever a chat message is received from the
server. It can be used to create different notifications for chat messages. Hook
functions can modify the `author`, `authorName`, `duration`, `rendered`,
`sticky`, `text`, and `timeStr` context properties to change how the message is
processed. The `text` and `timeStr` properties may contain HTML and come
pre-sanitized; plugins should be careful to sanitize any added user input to
avoid introducing an XSS vulnerability.
1. authorName - The user that wrote this message Context properties:
2. author - The authorID of the user that wrote the message
3. text - the message text
4. sticky (boolean) - if you want the gritter notification bubble to fade out on
its own or just sit there
5. timestamp - the timestamp of the chat message
6. timeStr - the timestamp as a formatted string
7. duration - for how long in milliseconds should the gritter notification
appear (0 to disable)
This hook is called on the client side whenever a chat message is received from * `authorName`: The display name of the user that wrote the message.
the server. It can be used to create different notifications for chat messages. * `author`: The author ID of the user that wrote the message.
Hoook functions can modify the `author`, `authorName`, `duration`, `sticky`, * `text`: Sanitized message HTML, with URLs wrapped like `<a
`text`, and `timeStr` context properties to change how the message is processed. href="url">url</a>`. (Note that `message.text` is not sanitized or processed
The `text` and `timeStr` properties may contain HTML, but plugins should be in any way.)
careful to sanitize any added user input to avoid introducing an XSS * `message`: The raw message object as received from the server, except with
vulnerability. time correction and a default `authorId` property if missing. Plugins must not
modify this object. Warning: Unlike `text`, `message.text` is not
pre-sanitized or processed in any way.
* `rendered` - Used to override the default message rendering. Initially set to
`null`. If the hook function sets this to a DOM element object or a jQuery
object, then that object will be used as the rendered message UI. Otherwise,
if this is set to `null`, then Etherpad will render a default UI for the
message using the other context properties.
* `sticky` (boolean): Whether the gritter notification should fade out on its
own or just sit there until manually closed.
* `timestamp`: When the chat message was sent (milliseconds since epoch),
corrected using the difference between the local clock and the server's clock.
* `timeStr`: The message timestamp as a formatted string.
* `duration`: How long (in milliseconds) to display the gritter notification (0
to disable).
## `chatSendMessage`
Called from: `src/static/js/chat.js`
This hook runs on the client side whenever the user sends a new chat message.
Plugins can mutate the message object to change the message text or add metadata
to control how the message will be rendered by the `chatNewMessage` hook.
Context properties:
* `message`: The message object that will be sent to the Etherpad server.
## collectContentPre ## collectContentPre

View file

@ -50,12 +50,13 @@ Things in context:
If this hook returns an error, the callback to the install function gets an error, too. This seems useful for adding in features when a particular plugin is installed. If this hook returns an error, the callback to the install function gets an error, too. This seems useful for adding in features when a particular plugin is installed.
## init_`<plugin name>` ## `init_<plugin name>`
Called from: src/static/js/pluginfw/plugins.js
Things in context: None Called from: `src/static/js/pluginfw/plugins.js`
This function is called after a specific plugin is initialized. This would probably be more useful than the previous two functions if you only wanted to add in features to one specific plugin. Run during startup after the named plugin is initialized.
Context properties: None
## expressConfigure ## expressConfigure
Called from: src/node/hooks/express.js Called from: src/node/hooks/express.js
@ -579,9 +580,9 @@ Things in context:
This hook allows plugins to grant temporary write access to a pad. It is called This hook allows plugins to grant temporary write access to a pad. It is called
for each incoming message from a client. If write access is granted, it applies for each incoming message from a client. If write access is granted, it applies
to the current message and all future messages from the same socket.io to the current message and all future messages from the same socket.io
connection until the next `CLIENT_READY` or `SWITCH_TO_PAD` message. Read-only connection until the next `CLIENT_READY` message. Read-only access is reset
access is reset **after** each `CLIENT_READY` or `SWITCH_TO_PAD` message, so **after** each `CLIENT_READY` message, so granting write access has no effect
granting write access has no effect for those message types. for those message types.
The handleMessageSecurity function must return a Promise. If the Promise The handleMessageSecurity function must return a Promise. If the Promise
resolves to `true`, write access is granted as described above. Returning resolves to `true`, write access is granted as described above. Returning
@ -807,36 +808,82 @@ Example:
exports.exportEtherpadAdditionalContent = () => ['comments']; exports.exportEtherpadAdditionalContent = () => ['comments'];
``` ```
## userLeave ## `import`
Called from src/node/handler/PadMessageHandler.js
This in context: Called from: `src/node/handler/ImportHandler.js`
1. session (including the pad id and author id) Called when a user submits a document for import, before the document is
converted to HTML. The hook function should return a truthy value if the hook
function elected to convert the document to HTML.
This hook gets called when an author leaves a pad. This is useful if you want to perform certain actions after a pad has been edited Context properties:
* `destFile`: The destination HTML filename.
* `fileEnding`: The lower-cased filename extension from `srcFile` **with leading
period** (examples: `'.docx'`, `'.html'`, `'.etherpad'`).
* `padId`: The identifier of the destination pad.
* `srcFile`: The document to convert.
## `userJoin`
Called from: `src/node/handler/PadMessageHandler.js`
Called after users have been notified that a new user has joined the pad.
Context properties:
* `authorId`: The user's author identifier.
* `displayName`: The user's display name.
* `padId`: The real (not read-only) identifier of the pad the user joined. This
MUST NOT be shared with any users that are connected with read-only access.
* `readOnly`: Whether the user only has read-only access.
* `readOnlyPadId`: The read-only identifier of the pad the user joined.
* `socket`: The socket.io Socket object.
Example: Example:
``` ```javascript
exports.userLeave = function(hook, session, callback) { exports.userJoin = async (hookName, {authorId, displayName, padId}) => {
console.log('%s left pad %s', session.author, session.padId); console.log(`${authorId} (${displayName}) joined pad ${padId});
}; };
``` ```
### clientReady ## `userLeave`
Called from src/node/handler/PadMessageHandler.js
This in context: Called from: `src/node/handler/PadMessageHandler.js`
1. message Called when a user disconnects from a pad. This is useful if you want to perform
certain actions after a pad has been edited.
This hook gets called when handling a CLIENT_READY which is the first message from the client to the server. Context properties:
* `authorId`: The user's author ID.
* `padId`: The pad's real (not read-only) identifier.
* `readOnly`: If truthy, the user only has read-only access.
* `readOnlyPadId`: The pad's read-only identifier.
* `socket`: The socket.io Socket object.
Example: Example:
``` ```javascript
exports.clientReady = function(hook, message) { exports.userLeave = async (hookName, {author, padId}) => {
console.log('Client has entered the pad' + message.padId); console.log(`${author} left pad ${padId}`);
}; };
``` ```
## `chatNewMessage`
Called from: `src/node/handler/PadMessageHandler.js`
Called when a user (or plugin) generates a new chat message, just before it is
saved to the pad and relayed to all connected users.
Context properties:
* `message`: The chat message object. Plugins can mutate this object to change
the message text or add custom metadata to control how the message will be
rendered by the `chatNewMessage` client-side hook. The message's `authorId`
property can be trusted (the server overwrites any client-provided author ID
value with the user's actual author ID before this hook runs).
* `padId`: The pad's real (not read-only) identifier.
* `pad`: The pad's Pad object.

View file

@ -19,7 +19,7 @@ All of the following instructions are as a member of the `docker` group.
By default, the Etherpad Docker image is built and run in `production` mode: no development dependencies are installed, and asset bundling speeds up page load time. By default, the Etherpad Docker image is built and run in `production` mode: no development dependencies are installed, and asset bundling speeds up page load time.
### Rebuilding with custom settings ### Rebuilding with custom settings
Edit `<BASEDIR>/settings.json.docker` at your will. When rebuilding the image, this file will be copied inside your image and renamed to `setting.json`. Edit `<BASEDIR>/settings.json.docker` at your will. When rebuilding the image, this file will be copied inside your image and renamed to `settings.json`.
**Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. For details, refer to `settings.json.template`. **Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. For details, refer to `settings.json.template`.
@ -211,7 +211,9 @@ For the editor container, you can also make it full width by adding `full-width-
| `FOCUS_LINE_PERCENTAGE_ARROW_UP` | Percentage of viewport height to be additionally scrolled when user presses arrow up in the line of the top of the viewport. Set to 0 to let the scroll to be handled as default by Etherpad | `0` | | `FOCUS_LINE_PERCENTAGE_ARROW_UP` | Percentage of viewport height to be additionally scrolled when user presses arrow up in the line of the top of the viewport. Set to 0 to let the scroll to be handled as default by Etherpad | `0` |
| `FOCUS_LINE_DURATION` | Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation | `0` | | `FOCUS_LINE_DURATION` | Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation | `0` |
| `FOCUS_LINE_CARET_SCROLL` | Flag to control if it should scroll when user places the caret in the last line of the viewport | `false` | | `FOCUS_LINE_CARET_SCROLL` | Flag to control if it should scroll when user places the caret in the last line of the viewport | `false` |
| `SOCKETIO_MAX_HTTP_BUFFER_SIZE` | The maximum size (in bytes) of a single message accepted via Socket.IO. If a client sends a larger message, its connection gets closed to prevent DoS (memory exhaustion) attacks. | `10000` |
| `LOAD_TEST` | Allow Load Testing tools to hit the Etherpad Instance. WARNING: this will disable security on the instance. | `false` | | `LOAD_TEST` | Allow Load Testing tools to hit the Etherpad Instance. WARNING: this will disable security on the instance. | `false` |
| `DUMP_ON_UNCLEAN_EXIT` | Enable dumping objects preventing a clean exit of Node.js. WARNING: this has a significant performance impact. | `false` |
| `EXPOSE_VERSION` | Expose Etherpad version in the web interface and in the Server http header. Do not enable on production machines. | `false` | | `EXPOSE_VERSION` | Expose Etherpad version in the web interface and in the Server http header. Do not enable on production machines. | `false` |

View file

@ -213,7 +213,9 @@
"user": "${DB_USER:undefined}", "user": "${DB_USER:undefined}",
"password": "${DB_PASS:undefined}", "password": "${DB_PASS:undefined}",
"charset": "${DB_CHARSET:undefined}", "charset": "${DB_CHARSET:undefined}",
"filename": "${DB_FILENAME:var/dirty.db}" "filename": "${DB_FILENAME:var/dirty.db}",
"collection": "${DB_COLLECTION:undefined}",
"url": "${DB_URL:undefined}"
}, },
/* /*
@ -480,7 +482,7 @@
* value to work properly, but increasing the value increases susceptibility * value to work properly, but increasing the value increases susceptibility
* to denial of service attacks (malicious clients can exhaust memory). * to denial of service attacks (malicious clients can exhaust memory).
*/ */
"maxHttpBufferSize": 10000 "maxHttpBufferSize": "${SOCKETIO_MAX_HTTP_BUFFER_SIZE:10000}"
}, },
/* /*
@ -493,7 +495,7 @@
/** /**
* Disable dump of objects preventing a clean exit * Disable dump of objects preventing a clean exit
*/ */
"dumpOnUncleanExit": false, "dumpOnUncleanExit": "${DUMP_ON_UNCLEAN_EXIT:false}",
/* /*
* Disable indentation on new line when previous line ends with some special * Disable indentation on new line when previous line ends with some special
@ -584,58 +586,6 @@
*/ */
"loglevel": "${LOGLEVEL:INFO}", "loglevel": "${LOGLEVEL:INFO}",
/*
* Logging configuration. See log4js documentation for further information:
* https://github.com/nomiddlename/log4js-node
*
* You can add as many appenders as you want here.
*/
"logconfig" :
{ "appenders": [
{ "type": "console"
//, "category": "access"// only logs pad access
}
/*
, { "type": "file"
, "filename": "your-log-file-here.log"
, "maxLogSize": 1024
, "backups": 3 // how many log files there're gonna be at max
//, "category": "test" // only log a specific category
}
*/
/*
, { "type": "logLevelFilter"
, "level": "warn" // filters out all log messages that have a lower level than "error"
, "appender":
{ Use whatever appender you want here }
}
*/
/*
, { "type": "logLevelFilter"
, "level": "error" // filters out all log messages that have a lower level than "error"
, "appender":
{ "type": "smtp"
, "subject": "An error occurred in your EPL instance!"
, "recipients": "bar@blurdybloop.com, baz@blurdybloop.com"
, "sendInterval": 300 // 60 * 5 = 5 minutes -- will buffer log messages; set to 0 to send a mail for every message
, "transport": "SMTP", "SMTP": { // see https://github.com/andris9/Nodemailer#possible-transport-methods
"host": "smtp.example.com", "port": 465,
"secureConnection": true,
"auth": {
"user": "foo@example.com",
"pass": "bar_foo"
}
}
}
}
*/
]
}, // logconfig
/* Override any strings found in locale directories */ /* Override any strings found in locale directories */
"customLocaleStrings": {} "customLocaleStrings": {}
} }

View file

@ -590,58 +590,6 @@
*/ */
"loglevel": "INFO", "loglevel": "INFO",
/*
* Logging configuration. See log4js documentation for further information:
* https://github.com/nomiddlename/log4js-node
*
* You can add as many appenders as you want here.
*/
"logconfig" :
{ "appenders": [
{ "type": "console"
//, "category": "access"// only logs pad access
}
/*
, { "type": "file"
, "filename": "your-log-file-here.log"
, "maxLogSize": 1024
, "backups": 3 // how many log files there're gonna be at max
//, "category": "test" // only log a specific category
}
*/
/*
, { "type": "logLevelFilter"
, "level": "warn" // filters out all log messages that have a lower level than "error"
, "appender":
{ Use whatever appender you want here }
}
*/
/*
, { "type": "logLevelFilter"
, "level": "error" // filters out all log messages that have a lower level than "error"
, "appender":
{ "type": "smtp"
, "subject": "An error occurred in your EPL instance!"
, "recipients": "bar@blurdybloop.com, baz@blurdybloop.com"
, "sendInterval": 300 // 60 * 5 = 5 minutes -- will buffer log messages; set to 0 to send a mail for every message
, "transport": "SMTP", "SMTP": { // see https://github.com/andris9/Nodemailer#possible-transport-methods
"host": "smtp.example.com", "port": 465,
"secureConnection": true,
"auth": {
"user": "foo@example.com",
"pass": "bar_foo"
}
}
}
}
*/
]
}, // logconfig
/* Override any strings found in locale directories */ /* Override any strings found in locale directories */
"customLocaleStrings": {}, "customLocaleStrings": {},

13
src/bin/doc/package-lock.json generated Normal file
View file

@ -0,0 +1,13 @@
{
"name": "node-doc-generator",
"version": "0.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"marked": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz",
"integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA=="
}
}
}

View file

@ -15,10 +15,12 @@ is_cmd node || fatal "Please install node.js ( https://nodejs.org )"
is_cmd npm || fatal "Please install npm ( https://npmjs.org )" is_cmd npm || fatal "Please install npm ( https://npmjs.org )"
# Check npm version # Check npm version
require_minimal_version "npm" $(get_program_version "npm") "$REQUIRED_NPM_MAJOR" "$REQUIRED_NPM_MINOR" require_minimal_version "npm" "$(get_program_version "npm")" \
"$REQUIRED_NPM_MAJOR" "$REQUIRED_NPM_MINOR"
# Check node version # Check node version
require_minimal_version "nodejs" $(get_program_version "node") "$REQUIRED_NODE_MAJOR" "$REQUIRED_NODE_MINOR" require_minimal_version "nodejs" "$(get_program_version "node")" \
"$REQUIRED_NODE_MAJOR" "$REQUIRED_NODE_MINOR"
# Get the name of the settings file # Get the name of the settings file
settings="settings.json" settings="settings.json"
@ -34,17 +36,14 @@ if [ ! -f "$settings" ]; then
cp settings.json.template "$settings" || exit 1 cp settings.json.template "$settings" || exit 1
fi fi
log "Ensure that all dependencies are up to date... If this is the first time you have run Etherpad please be patient." log "Installing dependencies..."
( (
mkdir -p node_modules mkdir -p node_modules &&
cd node_modules cd node_modules &&
[ -e ep_etherpad-lite ] || ln -s ../src ep_etherpad-lite { [ -d ep_etherpad-lite ] || ln -sf ../src ep_etherpad-lite; } &&
cd ep_etherpad-lite cd ep_etherpad-lite &&
npm ci --no-optional npm ci --no-optional
) || { ) || exit 1
rm -rf src/node_modules
exit 1
}
# Remove all minified data to force node creating it new # Remove all minified data to force node creating it new
log "Clearing minified cache..." log "Clearing minified cache..."

View file

@ -1,7 +1,7 @@
@echo off @echo off
:: Change directory to etherpad-lite root :: Change directory to etherpad-lite root
cd /D "%~dp0\.." cd /D "%~dp0\..\.."
:: Is node installed? :: Is node installed?
cmd /C node -e "" || ( echo "Please install node.js ( https://nodejs.org )" && exit /B 1 ) cmd /C node -e "" || ( echo "Please install node.js ( https://nodejs.org )" && exit /B 1 )
@ -16,7 +16,7 @@ mklink /D "ep_etherpad-lite" "..\src"
cd /D "ep_etherpad-lite" cd /D "ep_etherpad-lite"
cmd /C npm ci || exit /B 1 cmd /C npm ci || exit /B 1
cd /D "%~dp0\.." cd /D "%~dp0\..\.."
echo _ echo _
echo Clearing cache... echo Clearing cache...

View file

@ -1,5 +1,3 @@
.ep_initialized
.DS_Store .DS_Store
node_modules/ node_modules/
node_modules
npm-debug.log npm-debug.log

View file

@ -50,12 +50,6 @@
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/padurlsanitize" "expressCreateServer": "ep_etherpad-lite/node/hooks/express/padurlsanitize"
} }
}, },
{
"name": "padreadonly",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/padreadonly"
}
},
{ {
"name": "webaccess", "name": "webaccess",
"hooks": { "hooks": {

View file

@ -5,23 +5,38 @@
"Aftabuzzaman", "Aftabuzzaman",
"Al Riaz Uddin Ripon", "Al Riaz Uddin Ripon",
"Bellayet", "Bellayet",
"Greatder",
"Nasir8891", "Nasir8891",
"Sankarshan", "Sankarshan",
"Sibabrata Banerjee", "Sibabrata Banerjee",
"আফতাবুজ্জামান" "আফতাবুজ্জামান"
] ]
}, },
"admin.page-title": "প্রশাসক কেন্দ্র - ইথারপ্যাড",
"admin_plugins": "প্লাগিন ব্যবস্থাপক",
"admin_plugins.available": "বিদ্যমান প্লাগিন",
"admin_plugins.available_not-found": "প্লাগিন পাওয়া যায়নি।",
"admin_plugins.available_fetching": "আনা হচ্ছে...", "admin_plugins.available_fetching": "আনা হচ্ছে...",
"admin_plugins.available_install.value": "ইনস্টল করুন", "admin_plugins.available_install.value": "ইনস্টল করুন",
"admin_plugins.available_search.placeholder": "ইনস্টল করার জন্য প্লাগইন অনুসন্ধান করুন",
"admin_plugins.description": "বিবরণ", "admin_plugins.description": "বিবরণ",
"admin_plugins.installed": "ইন্সটল হওয়া প্লাগিনসমূহ", "admin_plugins.installed": "ইন্সটল হওয়া প্লাগিনসমূহ",
"admin_plugins.installed_fetching": "ইন্সটলকৃত প্লাগিন আনা হচ্ছে", "admin_plugins.installed_fetching": "ইন্সটলকৃত প্লাগিন আনা হচ্ছে",
"admin_plugins.installed_uninstall.value": "আনইনস্টল করুন", "admin_plugins.installed_uninstall.value": "আনইনস্টল করুন",
"admin_plugins.last-update": "সর্বশেষ হালনাগাদ", "admin_plugins.last-update": "সর্বশেষ হালনাগাদ",
"admin_plugins.name": "নাম", "admin_plugins.name": "নাম",
"admin_plugins.page-title": "প্লাগিন ব্যবস্থাপনা - ইথারপ্যাড",
"admin_plugins.version": "সংস্করণ", "admin_plugins.version": "সংস্করণ",
"admin_plugins_info": "সমস্যা সমাধানের তথ্য",
"admin_plugins_info.hooks": "ইন্সটলকৃত হুক",
"admin_plugins_info.hooks_client": "গ্রাহক পার্শ্বের হুক",
"admin_plugins_info.hooks_server": "সার্ভার পার্শ্বের হুক",
"admin_plugins_info.parts": "ইন্সটলকৃত অংশ",
"admin_plugins_info.plugins": "ইন্সটলকৃত প্লাগিন",
"admin_plugins_info.page-title": "প্লাগিন তথ্য - ইথারপ্যাড",
"admin_plugins_info.version": "ইথারপ্যাড সংস্করণ", "admin_plugins_info.version": "ইথারপ্যাড সংস্করণ",
"admin_plugins_info.version_latest": "সাম্প্রতিক উপলব্ধ সংস্করণ", "admin_plugins_info.version_latest": "সাম্প্রতিক উপলব্ধ সংস্করণ",
"admin_plugins_info.version_number": "সংস্করণ সংখ্যা",
"admin_settings": "সেটিংসমূহ", "admin_settings": "সেটিংসমূহ",
"admin_settings.current": "বর্তমান কনফিগারেশন", "admin_settings.current": "বর্তমান কনফিগারেশন",
"admin_settings.current_restart.value": "ইথারপ্যাড পুনরায় চালু করুন", "admin_settings.current_restart.value": "ইথারপ্যাড পুনরায় চালু করুন",

View file

@ -5,8 +5,10 @@
"Espeox", "Espeox",
"Jl", "Jl",
"Lliehu", "Lliehu",
"MITO",
"Maantietäjä", "Maantietäjä",
"Macofe", "Macofe",
"Markus Mikkonen",
"MrTapsa", "MrTapsa",
"Nedergard", "Nedergard",
"Nike", "Nike",
@ -18,8 +20,11 @@
"VezonThunder" "VezonThunder"
] ]
}, },
"admin.page-title": "Ylläpitäjän kojelauta - Etherpad",
"admin_plugins": "Lisäosien hallinta",
"admin_plugins.available": "Saatavilla olevat liitännäiset", "admin_plugins.available": "Saatavilla olevat liitännäiset",
"admin_plugins.available_install.value": "Lataa", "admin_plugins.available_not-found": "Lisäosia ei löytynyt.",
"admin_plugins.available_install.value": "Asenna",
"admin_plugins.available_search.placeholder": "Etsi asennettavia laajennuksia", "admin_plugins.available_search.placeholder": "Etsi asennettavia laajennuksia",
"admin_plugins.description": "Kuvaus", "admin_plugins.description": "Kuvaus",
"admin_plugins.installed": "Asennetut laajennukset", "admin_plugins.installed": "Asennetut laajennukset",
@ -43,7 +48,7 @@
"admin_settings": "Asetukset", "admin_settings": "Asetukset",
"admin_settings.current": "Nykyinen kokoonpano", "admin_settings.current": "Nykyinen kokoonpano",
"admin_settings.current_example-devel": "Esimerkki kehitysasetusten mallista", "admin_settings.current_example-devel": "Esimerkki kehitysasetusten mallista",
"admin_settings.current_save.value": "Tallenna Asetukset", "admin_settings.current_save.value": "Tallenna asetukset",
"admin_settings.page-title": "asetukset - Etherpad", "admin_settings.page-title": "asetukset - Etherpad",
"index.newPad": "Uusi muistio", "index.newPad": "Uusi muistio",
"index.createOpenPad": "tai luo tai avaa muistio nimellä:", "index.createOpenPad": "tai luo tai avaa muistio nimellä:",
@ -67,7 +72,7 @@
"pad.colorpicker.save": "Tallenna", "pad.colorpicker.save": "Tallenna",
"pad.colorpicker.cancel": "Peru", "pad.colorpicker.cancel": "Peru",
"pad.loading": "Ladataan…", "pad.loading": "Ladataan…",
"pad.noCookie": "Evästettä ei löytynyt. Ole hyvä, ja salli evästeet selaimessasi!", "pad.noCookie": "Evästettä ei löytynyt. Ole hyvä, ja salli evästeet selaimessasi! Istuntoasi ja asetuksiasi ei tulla tallentamaan vierailujen välillä. Tämä voi johtua siitä, että Etherpad on sisällytetty iFrameen joissain selaimissa. Varmistathan että Etherpad on samalla subdomainilla/domainilla kuin ylätason iFrame",
"pad.permissionDenied": "Käyttöoikeutesi eivät riitä tämän muistion käyttämiseen.", "pad.permissionDenied": "Käyttöoikeutesi eivät riitä tämän muistion käyttämiseen.",
"pad.settings.padSettings": "Muistion asetukset", "pad.settings.padSettings": "Muistion asetukset",
"pad.settings.myView": "Oma näkymä", "pad.settings.myView": "Oma näkymä",

View file

@ -21,7 +21,7 @@
"pad.toolbar.timeslider.title": "Glissa-tempore", "pad.toolbar.timeslider.title": "Glissa-tempore",
"pad.toolbar.savedRevision.title": "Version salveguardate", "pad.toolbar.savedRevision.title": "Version salveguardate",
"pad.toolbar.settings.title": "Configuration", "pad.toolbar.settings.title": "Configuration",
"pad.toolbar.embed.title": "Divider e incorporar iste pad", "pad.toolbar.embed.title": "Condivider e incorporar iste pad",
"pad.toolbar.showusers.title": "Monstrar le usatores de iste pad", "pad.toolbar.showusers.title": "Monstrar le usatores de iste pad",
"pad.colorpicker.save": "Salveguardar", "pad.colorpicker.save": "Salveguardar",
"pad.colorpicker.cancel": "Cancellar", "pad.colorpicker.cancel": "Cancellar",

View file

@ -9,6 +9,7 @@
"Revi", "Revi",
"SeoJeongHo", "SeoJeongHo",
"Ykhwong", "Ykhwong",
"그냥기여자",
"아라" "아라"
] ]
}, },
@ -132,6 +133,7 @@
"pad.chat.loadmessages": "더 많은 메시지 불러오기", "pad.chat.loadmessages": "더 많은 메시지 불러오기",
"pad.chat.stick.title": "채팅을 화면에 고정", "pad.chat.stick.title": "채팅을 화면에 고정",
"pad.chat.writeMessage.placeholder": "여기에 메시지를 적으십시오", "pad.chat.writeMessage.placeholder": "여기에 메시지를 적으십시오",
"timeslider.followContents": "패드 콘텐츠의 갱신 주시하기",
"timeslider.pageTitle": "{{appTitle}} 시간슬라이더", "timeslider.pageTitle": "{{appTitle}} 시간슬라이더",
"timeslider.toolbar.returnbutton": "패드로 돌아가기", "timeslider.toolbar.returnbutton": "패드로 돌아가기",
"timeslider.toolbar.authors": "저자:", "timeslider.toolbar.authors": "저자:",

View file

@ -77,7 +77,7 @@
"pad.settings.about": "За додатоков", "pad.settings.about": "За додатоков",
"pad.settings.poweredBy": "Овозможено од", "pad.settings.poweredBy": "Овозможено од",
"pad.importExport.import_export": "Увоз/Извоз", "pad.importExport.import_export": "Увоз/Извоз",
"pad.importExport.import": "Подигање на било каква текстуална податотека или документ", "pad.importExport.import": "Подигање на било каква било текстуална податотека или документ",
"pad.importExport.importSuccessful": "Успешно!", "pad.importExport.importSuccessful": "Успешно!",
"pad.importExport.export": "Извези ја тековната тетратка како", "pad.importExport.export": "Извези ја тековната тетратка како",
"pad.importExport.exportetherpad": "Etherpad", "pad.importExport.exportetherpad": "Etherpad",

169
src/locales/my.json Normal file
View file

@ -0,0 +1,169 @@
{
"@metadata": {
"authors": [
"Andibecker",
"Dr Lotus Black"
]
},
"admin.page-title": "စီမံခန့်ခွဲသူဒိုင်ခွက် - Etherpad",
"admin_plugins": "ပလပ်အင်မန်နေဂျာ",
"admin_plugins.available": "ရနိုင်သော plugins များ",
"admin_plugins.available_not-found": "ပလပ်အင်များမတွေ့ပါ။",
"admin_plugins.available_fetching": "ရယူနေသည်…",
"admin_plugins.available_install.value": "အင်စတော လုပ်ပါ",
"admin_plugins.available_search.placeholder": "အင်စတောလုပ်ဖို့ plugins များကိုရှာပါ",
"admin_plugins.description": "ဖော်ပြချက်",
"admin_plugins.installed": "plugins များထည့်သွင်းထားသည်",
"admin_plugins.installed_fetching": "ထည့်သွင်းထားသောပလပ်အင်များကိုရယူနေသည်…",
"admin_plugins.installed_nothing": "သင်မည်သည့် plugins ကိုမျှမထည့်သွင်းရသေးပါ။",
"admin_plugins.installed_uninstall.value": "ဖြုတ်ပါ",
"admin_plugins.last-update": "နောက်ဆုံးအပ်ဒိတ်",
"admin_plugins.name": "နာမည်",
"admin_plugins.page-title": "ပလပ်အင်မန်နေဂျာ - Etherpad",
"admin_plugins.version": "ဗားရှင်း",
"admin_plugins_info": "သတင်းအချက်အလက်ပြဿနာဖြေရှင်းခြင်း",
"admin_plugins_info.hooks": "ချိတ်များတပ်ဆင်ထားသည်",
"admin_plugins_info.hooks_client": "Client-side ချိတ်",
"admin_plugins_info.hooks_server": "Server-side ချိတ်",
"admin_plugins_info.parts": "တပ်ဆင်ထားသော အစိတ်အပိုင်းများ",
"admin_plugins_info.plugins": "plugins များထည့်သွင်းထားသည်",
"admin_plugins_info.page-title": "ပလပ်အင်အချက်အလက် - Etherpad",
"admin_plugins_info.version": "Etherpad ဗားရှင်း",
"admin_plugins_info.version_latest": "နောက်ဆုံးရနိုင်သောဗားရှင်း",
"admin_plugins_info.version_number": "ဗားရှင်းနံပါတ်",
"admin_settings": "အပြင်အဆင်များ",
"admin_settings.current": "လက်ရှိဖွဲ့စည်းမှု",
"admin_settings.current_example-devel": "နမူနာဖွံ့ဖြိုးတိုးတက်မှုဆက်တင်နမူနာ",
"admin_settings.current_example-prod": "နမူနာထုတ်လုပ်မှုဆက်တင်ပုံစံ",
"admin_settings.current_restart.value": "Etherpad ကိုပြန်လည်စတင်ပါ",
"admin_settings.current_save.value": "ဆက်တင်များကိုသိမ်းပါ",
"admin_settings.page-title": "ဆက်တင်များ - Etherpad",
"index.newPad": "Pad အသစ်",
"index.createOpenPad": "သို့မဟုတ် Pad နှင့်နာမည်ဖွင့်ပါ။",
"index.openPad": "ရှိပြီးသား Pad ကိုနာမည်နှင့်ဖွင့်ပါ။",
"pad.toolbar.bold.title": "စာလုံးအကြီး (Ctrl+B)",
"pad.toolbar.italic.title": "စာလုံးစောင်း (Ctrl+I)",
"pad.toolbar.underline.title": "မျဉ်းသားရန် (Ctrl+U)",
"pad.toolbar.strikethrough.title": "ဖြတ်တောက်ခြင်း (Ctrl+5)",
"pad.toolbar.ol.title": "အမှာစာစာရင်း (Ctrl+Shift+N)",
"pad.toolbar.ul.title": "Unordered စာရင်း (Ctrl+Shift+L)",
"pad.toolbar.indent.title": "အင်တင်း (TAB)",
"pad.toolbar.unindent.title": "အပြင် (Shift+TAB)",
"pad.toolbar.undo.title": "ပြန်လုပ်ရန် (Ctrl+Z)",
"pad.toolbar.redo.title": "ပြန်လုပ်ရန် (Ctrl+Y)",
"pad.toolbar.clearAuthorship.title": "စာရေးသူအရောင်များကိုရှင်းလင်းပါ (Ctrl+Shift+C)",
"pad.toolbar.import_export.title": "ကွဲပြားခြားနားသောဖိုင်အမျိုးအစားများမှ/သွင်းကုန်/တင်ပို့ပါ",
"pad.toolbar.timeslider.title": "Timeslider",
"pad.toolbar.savedRevision.title": "ပြန်လည်တည်းဖြတ်ပါ",
"pad.toolbar.settings.title": "အပြင်အဆင်များ",
"pad.toolbar.embed.title": "ဒီ pad ကို Share လုပ်ပြီးမြှုပ်လိုက်ပါ",
"pad.toolbar.showusers.title": "ဤ pad ပေါ်တွင်အသုံးပြုသူများကိုပြပါ",
"pad.colorpicker.save": "သိမ်းရန်",
"pad.colorpicker.cancel": "မလုပ်တော့ပါ",
"pad.loading": "ဝန်ဆွဲတင်နေသည်...",
"pad.noCookie": "ကွတ်ကီးကိုရှာမတွေ့ပါ။ ကျေးဇူးပြု၍ သင်၏ browser တွင် cookies များကိုခွင့်ပြုပါ။ လည်ပတ်မှုများအကြားသင်၏အစည်းအဝေးနှင့်ဆက်တင်များကိုသိမ်းဆည်းမည်မဟုတ်ပါ။ ၎င်းသည်အချို့သောဘရောင်ဇာများတွင် iFrame တွင် iFrame တွင်ထည့်သွင်းခံရခြင်းကြောင့်ဖြစ်နိုင်သည်။ Etherpad သည် parent iFrame ကဲ့သို့တူညီသော subdomain/domain ပေါ်တွင်သေချာပါစေ",
"pad.permissionDenied": "သင်ဤ pad ကိုသုံးခွင့်မရှိပါ",
"pad.settings.padSettings": "Pad ဆက်တင်များ",
"pad.settings.myView": "ငါ့အမြင်",
"pad.settings.stickychat": "ဖန်သားပြင်ပေါ်တွင်အမြဲစကားပြောပါ",
"pad.settings.chatandusers": "ချတ်နှင့်အသုံးပြုသူများကိုပြပါ",
"pad.settings.colorcheck": "စာရေးသူအရောင်များ",
"pad.settings.linenocheck": "လိုင်းနံပါတ်များ",
"pad.settings.rtlcheck": "အကြောင်းအရာကိုညာမှဘယ်သို့ဖတ်ပါ။",
"pad.settings.fontType": "ဖောင့်အမျိုးအစား",
"pad.settings.fontType.normal": "သာမန်",
"pad.settings.language": "ဘာသာစကား:",
"pad.settings.about": "အကြောင်း",
"pad.settings.poweredBy": "မှပံ့ပိုးသည်",
"pad.importExport.import_export": "သွင်းကုန်/ပို့ကုန်",
"pad.importExport.import": "မည်သည့်စာသားဖိုင်သို့မဆိုစာရွက်စာတမ်းတင်ပါ",
"pad.importExport.importSuccessful": "အောင်မြင်သည်။",
"pad.importExport.export": "လက်ရှိ pad ကိုအောက်ပါအတိုင်းတင်ပို့ပါ။",
"pad.importExport.exportetherpad": "Etherpad ပါ",
"pad.importExport.exporthtml": "HTML",
"pad.importExport.exportplain": "ရိုးရိုးစာသား",
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "ပီဒီအက်ဖ်",
"pad.importExport.exportopen": "ODF (စာရွက်စာတမ်းဖွင့်ပုံစံ)",
"pad.importExport.abiword.innerHTML": "သင်ရိုးရိုးစာသားများ (သို့) HTML ပုံစံများဖြင့်သာတင်သွင်းနိုင်သည်။ ပိုမိုအဆင့်မြင့်သောသွင်းကုန်အင်္ဂါရပ်များအတွက် ကျေးဇူးပြု၍ ကျေးဇူးပြု၍ <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\"> AbiWord သို့မဟုတ် LibreOffice </a> ကို install လုပ်ပါ။",
"pad.modals.connected": "ချိတ်ဆက်ထားသည်။",
"pad.modals.reconnecting": "သင်၏ pad သို့ပြန်လည်ချိတ်ဆက်နေသည်…",
"pad.modals.forcereconnect": "ပြန်လည်ချိတ်ဆက်ခိုင်းပါ",
"pad.modals.reconnecttimer": "ပြန်လည်ချိတ်ဆက်ရန်ကြိုးစားနေသည်",
"pad.modals.cancel": "မလုပ်တော့ပါ",
"pad.modals.userdup": "ပယ်ဖျက်",
"pad.modals.userdup.explanation": "ဤ pad ကိုဤကွန်ပျူတာရှိ browser window တစ်ခုထက်ပိုဖွင့်ထားပုံရသည်။",
"pad.modals.userdup.advice": "၎င်းအစားဤဝင်းဒိုးကိုသုံးရန်ပြန်လည်ချိတ်ဆက်ပါ။",
"pad.modals.unauth": "လုပ်ပိုင်ခွင့်မရှိပါ",
"pad.modals.unauth.explanation": "ဤစာမျက်နှာကိုကြည့်နေစဉ်သင်၏ခွင့်ပြုချက်များပြောင်းသွားသည်။ ပြန်လည်ချိတ်ဆက်ရန်ကြိုးစားပါ။",
"pad.modals.looping.explanation": "synchronization server နှင့်ဆက်သွယ်မှုပြဿနာများရှိသည်။",
"pad.modals.looping.cause": "သဟဇာတမဖြစ်သည့် firewall (သို့) proxy မှတဆင့်သင်ဆက်သွယ်နိုင်သည်။",
"pad.modals.initsocketfail": "ဆာဗာကို ဆက်သွယ်၍ မရပါ။",
"pad.modals.initsocketfail.explanation": "ထပ်တူပြုခြင်းဆာဗာသို့မချိတ်ဆက်နိုင်ခဲ့ပါ။",
"pad.modals.initsocketfail.cause": "၎င်းသည်သင်၏ browser (သို့) သင်၏အင်တာနက်ဆက်သွယ်မှုပြဿနာကြောင့်ဖြစ်နိုင်သည်။",
"pad.modals.slowcommit.explanation": "ဆာဗာကမတုံ့ပြန်ပါ။",
"pad.modals.slowcommit.cause": "၎င်းသည်ကွန်ယက်ချိတ်ဆက်မှုဆိုင်ရာပြဿနာများကြောင့်ဖြစ်နိုင်သည်။",
"pad.modals.badChangeset.explanation": "သင်ပြုလုပ်သောတည်းဖြတ်မှုကို synchronization server မှတရားမ င်ခွဲခြားခဲ့သည်။",
"pad.modals.badChangeset.cause": "၎င်းသည်မှားယွင်းသော server ဖွဲ့စည်းမှုပုံစံ (သို့) အခြားမမျှော်လင့်သောအပြုအမူများကြောင့်ဖြစ်နိုင်သည်။ ဤအရာသည်မှားယွင်းမှုတစ်ခုဟုသင်ခံစားရပါက န်ဆောင်မှုစီမံခန့်ခွဲသူအားဆက်သွယ်ပါ။ တည်းဖြတ်မှုကိုဆက်လက်လုပ်ဆောင်နိုင်ရန်ပြန်လည်ချိတ်ဆက်ကြည့်ပါ။",
"pad.modals.corruptPad.explanation": "သင်ရယူရန်ကြိုးစားနေသော pad သည်ယိုယွင်းနေသည်။",
"pad.modals.corruptPad.cause": "၎င်းသည်မှားယွင်းသော server ဖွဲ့စည်းမှုပုံစံ (သို့) အခြားမမျှော်လင့်သောအပြုအမူများကြောင့်ဖြစ်နိုင်သည်။ ကျေးဇူးပြု၍ န်ဆောင်မှုစီမံခန့်ခွဲသူကိုဆက်သွယ်ပါ။",
"pad.modals.deleted": "ဖျက်လိုက်သည်။",
"pad.modals.deleted.explanation": "ဒီအကွက်ကိုဖယ်ရှားပြီးပါပြီ။",
"pad.modals.rateLimited": "နှုန်းကန့်သတ်။",
"pad.modals.rateLimited.explanation": "မင်းဒီအဆက်အသွယ်ကိုဒီ pad မှာအရမ်းများတဲ့မက်ဆေ့ဂျ်တွေပို့ခဲ့တယ်။",
"pad.modals.rejected.explanation": "ဆာဗာသည်သင်၏ဘရောင်ဇာမှပေးပို့သောစာကိုငြင်းပယ်ခဲ့သည်။",
"pad.modals.rejected.cause": "သင် pad ကိုကြည့်နေစဉ်ဆာဗာကိုမွမ်းမံခဲ့ပေမည်၊ သို့မဟုတ် Etherpad တွင်ချို့ယွင်းချက်တစ်ခုရှိနေနိုင်သည်။ စာမျက်နှာကိုပြန်တင်ကြည့်ပါ။",
"pad.modals.disconnected": "မင်းအဆက်အသွယ်ဖြတ်လိုက်ပြီ။",
"pad.modals.disconnected.explanation": "ဆာဗာနှင့်ချိတ်ဆက်မှုပြတ်တောက်သွားသည်",
"pad.modals.disconnected.cause": "ဆာဗာမရနိုင်ပါ။ ဤသို့ဆက်ဖြစ်နေပါက ၀န်ဆောင်မှုစီမံခန့်ခွဲသူအား အကြောင်းကြားပါ။",
"pad.share": "ဒီစာရွက်ကိုမျှဝေပါ",
"pad.share.readonly": "ဖတ်သာကြည့်ပါ",
"pad.share.link": "လင့်",
"pad.share.emebdcode": "URL ထည့်ပါ",
"pad.chat": "စကားပြောမယ်",
"pad.chat.title": "ဒီ pad အတွက်စကားပြောခန်းကိုဖွင့်ပါ။",
"pad.chat.loadmessages": "နောက်ထပ်မက်ဆေ့ခ်ျများတင်ပါ",
"pad.chat.stick.title": "ချတ်ကိုမျက်နှာပြင်သို့ကပ်ပါ",
"pad.chat.writeMessage.placeholder": "မင်းရဲ့စာကိုဒီမှာရေးပါ",
"timeslider.followContents": "pad အကြောင်းအရာနောက်ဆုံးသတင်းများကိုလိုက်နာပါ",
"timeslider.pageTitle": "{{appTitle}} Timeslider",
"timeslider.toolbar.returnbutton": "ပလက်ဖောင်းသို့ပြန်သွားရန်",
"timeslider.toolbar.authors": "ရေးသားသူ -",
"timeslider.toolbar.authorsList": "စာရေးသူမရှိပါ",
"timeslider.toolbar.exportlink.title": "တင်ပို့သည်",
"timeslider.exportCurrent": "လက်ရှိဗားရှင်းအဖြစ်",
"timeslider.version": "ဗားရှင်း {{version}}",
"timeslider.saved": "သိမ်းထားသည် {{month}} {{day}}, {{year}}",
"timeslider.playPause": "Pad အကြောင်းအရာများပြန်ဖွင့်ခြင်း / ခဏရပ်ခြင်း",
"timeslider.backRevision": "ဤ Pad ရှိပြန်လည်သုံးသပ်ခြင်းကိုပြန်သွားပါ",
"timeslider.forwardRevision": "ဤ Pad ၌တည်းဖြတ်မှုတစ်ခုကိုရှေ့ဆက်ပါ",
"timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}: {{minutes}}: {{seconds}}",
"timeslider.month.january": "ဇန်နဝါရီ",
"timeslider.month.february": "ဖေဖော်ဝါရီ",
"timeslider.month.march": "မတ်",
"timeslider.month.april": "ဧပြီ",
"timeslider.month.may": "မေ",
"timeslider.month.june": "ဇွန်",
"timeslider.month.july": "ဇူလိုင်",
"timeslider.month.august": "ဩဂုတ်",
"timeslider.month.september": "စက်တင်ဘာ",
"timeslider.month.october": "အောက်တိုဘာ",
"timeslider.month.november": "နို​ဝင်​ဘာ​",
"timeslider.month.december": "ဒီဇင်ဘာ",
"timeslider.unnamedauthors": "{{num}} အမည်မဖော်လိုသူ {[အများကိန်း (num) one: author၊ အခြား: author]}",
"pad.savedrevs.marked": "ယခုပြန်လည်တည်းဖြတ်မှုအားသိမ်းဆည်းထားသောတည်းဖြတ်မှုတစ်ခုအဖြစ်အမှတ်အသားပြုထားသည်",
"pad.savedrevs.timeslider": "timeslider ကိုသွားခြင်းဖြင့်သိမ်းဆည်းထားသောပြန်လည်တည်းဖြတ်ချက်များကိုသင်မြင်နိုင်သည်",
"pad.userlist.entername": "မင်းနာမည်ထည့်ပါ",
"pad.userlist.unnamed": "အမည်မဲ့",
"pad.editbar.clearcolors": "စာရွက်စာတမ်းတစ်ခုလုံးတွင်စာရေးသူအရောင်များကိုရှင်းလိုပါသလား။ ဒါကိုပြန် ပြင်၍ မရပါ",
"pad.impexp.importbutton": "ယခုတင်သွင်းပါ",
"pad.impexp.importing": "တင်သွင်းနေသည် ...",
"pad.impexp.confirmimport": "ဖိုင်တစ်ခုတင်သွင်းခြင်းသည် pad ၏လက်ရှိစာသားကိုထပ်ရေးလိမ့်မည်။ သင်ရှေ့ဆက်လိုသည်မှာသေချာသလား။",
"pad.impexp.convertFailed": "ဤဖိုင်ကိုကျွန်ုပ်တို့မတင်သွင်းနိုင်ခဲ့ပါ။ ကျေးဇူးပြု၍ အခြားစာရွက်စာတမ်းပုံစံတစ်ခုကိုသုံးပါသို့မဟုတ်ကိုယ်တိုင်ကူးယူပါ",
"pad.impexp.padHasData": "ဤ Pad သည်အပြောင်းအလဲများရှိနေပြီးဖြစ်သောကြောင့် ကျေးဇူးပြု၍ ဤဖိုင်ကိုတင်သွင်းနိုင်ခဲ့ခြင်းမရှိပါ၊ ကျေးဇူးပြု၍ pad အသစ်သို့တင်သွင်းပါ",
"pad.impexp.uploadFailed": "အပ်လုဒ်တင်ခြင်းမအောင်မြင်ပါ၊ ကျေးဇူးပြု၍ ထပ်ကြိုးစားပါ",
"pad.impexp.importfailed": "တင်သွင်းမှုမအောင်မြင်ပါ",
"pad.impexp.copypaste": "ကျေးဇူးပြု၍ ကူးထည့်ပါ",
"pad.impexp.exportdisabled": "{{type}} ပုံစံအဖြစ်ထုတ်ယူခြင်းကိုပိတ်ထားသည်။ အသေးစိတ်အတွက် ကျေးဇူးပြု၍ သင်၏စနစ်စီမံခန့်ခွဲသူကိုဆက်သွယ်ပါ။",
"pad.impexp.maxFileSize": "ဖိုင်ဆိုဒ်အရမ်းကြီးတယ်။ သွင်းကုန်အတွက်ခွင့်ပြုထားသောဖိုင်အရွယ်အစားကိုမြှင့်ရန်သင်၏ site စီမံခန့်ခွဲသူနှင့်ဆက်သွယ်ပါ"
}

View file

@ -7,6 +7,7 @@
"Macofe", "Macofe",
"Mainframe98", "Mainframe98",
"Marcelhospers", "Marcelhospers",
"McDutchie",
"PonkoSasuke", "PonkoSasuke",
"Rickvl", "Rickvl",
"Robin van der Vliet", "Robin van der Vliet",
@ -57,7 +58,7 @@
"pad.toolbar.ol.title": "Geordende lijst (Ctrl+Shift+N)", "pad.toolbar.ol.title": "Geordende lijst (Ctrl+Shift+N)",
"pad.toolbar.ul.title": "Ongeordende lijst (Ctrl+Shift+L)", "pad.toolbar.ul.title": "Ongeordende lijst (Ctrl+Shift+L)",
"pad.toolbar.indent.title": "Inspringen (Tab)", "pad.toolbar.indent.title": "Inspringen (Tab)",
"pad.toolbar.unindent.title": "Inspringing verkleinen (Shift+Tab)", "pad.toolbar.unindent.title": "Uitspringen (Shift+Tab)",
"pad.toolbar.undo.title": "Ongedaan maken (Ctrl-Z)", "pad.toolbar.undo.title": "Ongedaan maken (Ctrl-Z)",
"pad.toolbar.redo.title": "Opnieuw uitvoeren (Ctrl-Y)", "pad.toolbar.redo.title": "Opnieuw uitvoeren (Ctrl-Y)",
"pad.toolbar.clearAuthorship.title": "Kleuren auteurs wissen (Ctrl+Shift+C)", "pad.toolbar.clearAuthorship.title": "Kleuren auteurs wissen (Ctrl+Shift+C)",
@ -70,7 +71,7 @@
"pad.colorpicker.save": "Opslaan", "pad.colorpicker.save": "Opslaan",
"pad.colorpicker.cancel": "Annuleren", "pad.colorpicker.cancel": "Annuleren",
"pad.loading": "Bezig met laden…", "pad.loading": "Bezig met laden…",
"pad.noCookie": "Er kon geen cookie gevonden worden. Zorg ervoor dat uw browser cookies accepteert.", "pad.noCookie": "Er kon geen cookie gevonden worden. Zorg ervoor dat uw browser cookies accepteert. Uw sessie en instellingen worden tussen bezoeken niet opgeslagen. Dit kan te wijten zijn aan het feit dat Etherpad in sommige browsers wordt opgenomen in een iFrame. Zorg ervoor dat Etherpad zich op hetzelfde subdomein/domein bevindt als het bovenliggende iFrame.",
"pad.permissionDenied": "U hebt geen rechten om deze pad te bekijken", "pad.permissionDenied": "U hebt geen rechten om deze pad te bekijken",
"pad.settings.padSettings": "Padinstellingen", "pad.settings.padSettings": "Padinstellingen",
"pad.settings.myView": "Mijn overzicht", "pad.settings.myView": "Mijn overzicht",
@ -93,9 +94,9 @@
"pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "Pdf", "pad.importExport.exportpdf": "Pdf",
"pad.importExport.exportopen": "ODF (Open Document Format)", "pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "U kunt alleen importeren vanuit Tekst zonder opmaak of een HTML-opmaak. <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">Installeer AbiWord</a> om meer geavanceerde importmogelijkheden te krijgen.", "pad.importExport.abiword.innerHTML": "U kunt alleen importeren vanuit tekst zonder opmaak of met HTML-opmaak. <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">Installeer AbiWord of LibreOffice</a> om meer geavanceerde importmogelijkheden te krijgen.",
"pad.modals.connected": "Verbonden.", "pad.modals.connected": "Verbonden.",
"pad.modals.reconnecting": "Opnieuw verbinding maken met uw pad...", "pad.modals.reconnecting": "Opnieuw verbinding maken met uw pad",
"pad.modals.forcereconnect": "Opnieuw verbinden", "pad.modals.forcereconnect": "Opnieuw verbinden",
"pad.modals.reconnecttimer": "Proberen te verbinden over", "pad.modals.reconnecttimer": "Proberen te verbinden over",
"pad.modals.cancel": "Annuleren", "pad.modals.cancel": "Annuleren",

View file

@ -49,7 +49,7 @@
"pad.modals.cancel": "رد", "pad.modals.cancel": "رد",
"pad.modals.userdup": "هڪ ٻي دري ۾ کليل", "pad.modals.userdup": "هڪ ٻي دري ۾ کليل",
"pad.modals.unauth": "اختيار نه آهي", "pad.modals.unauth": "اختيار نه آهي",
"pad.modals.initsocketfail": "سَروَرَ کي پڄي نٿو سگھجي.", "pad.modals.initsocketfail": "سَروَرَ تائين پُڄي نٿو سگهجي.",
"pad.modals.slowcommit.explanation": "سَروَر جواب نٿو ڏي.", "pad.modals.slowcommit.explanation": "سَروَر جواب نٿو ڏي.",
"pad.modals.corruptPad.explanation": "جيڪا پٽي توهان حاصل ڪرڻ چاهيو ٿا اها بدعنوان آهي.", "pad.modals.corruptPad.explanation": "جيڪا پٽي توهان حاصل ڪرڻ چاهيو ٿا اها بدعنوان آهي.",
"pad.modals.deleted": "ختم ڪيل.", "pad.modals.deleted": "ختم ڪيل.",

169
src/locales/sw.json Normal file
View file

@ -0,0 +1,169 @@
{
"@metadata": {
"authors": [
"Andibecker",
"Edwingudfriend",
"Muddyb"
]
},
"admin.page-title": "Dashibodi ya Usimamizi - Etherpad",
"admin_plugins": "Meneja wa programu-jalizi",
"admin_plugins.available": "Programu-jalizi zinazopatikana",
"admin_plugins.available_not-found": "Hakuna programu-jalizi zilizopatikana.",
"admin_plugins.available_fetching": "Inaleta…",
"admin_plugins.available_install.value": "Sakinisha",
"admin_plugins.available_search.placeholder": "Tafuta programu-jalizi ili usakinishe",
"admin_plugins.description": "Maelezo",
"admin_plugins.installed": "Programu-jalizi zilizosanikishwa",
"admin_plugins.installed_fetching": "Inaleta programu-jalizi zilizosakinishwa…",
"admin_plugins.installed_nothing": "Bado hujasakinisha programu-jalizi yoyote.",
"admin_plugins.installed_uninstall.value": "Ondoa",
"admin_plugins.last-update": "Sasisho la mwisho",
"admin_plugins.name": "Jina",
"admin_plugins.page-title": "Meneja wa programu-jalizi - Etherpad",
"admin_plugins.version": "Toleo",
"admin_plugins_info": "Maelezo ya utatuzi",
"admin_plugins_info.hooks": "Ndoano zilizowekwa",
"admin_plugins_info.hooks_client": "Kulabu za mteja",
"admin_plugins_info.hooks_server": "Kulabu za upande wa seva",
"admin_plugins_info.parts": "Sehemu zilizowekwa",
"admin_plugins_info.plugins": "Programu-jalizi zilizosanikishwa",
"admin_plugins_info.page-title": "Habari ya programu-jalizi - Etherpad",
"admin_plugins_info.version": "Toleo la Etherpad",
"admin_plugins_info.version_latest": "Toleo la hivi karibuni linalopatikana",
"admin_plugins_info.version_number": "Nambari ya toleo",
"admin_settings": "Mipangilio",
"admin_settings.current": "Usanidi wa sasa",
"admin_settings.current_example-devel": "Mfano mipangilio ya mipangilio ya maendeleo",
"admin_settings.current_example-prod": "Mfano mipangilio ya mipangilio ya uzalishaji",
"admin_settings.current_restart.value": "Anzisha upya Etherpad",
"admin_settings.current_save.value": "Hifadhi Mipangilio",
"admin_settings.page-title": "Mipangilio - Etherpad",
"index.newPad": "Pad Mpya",
"index.createOpenPad": "au tunga/fungua Pad yenye jina:",
"index.openPad": "fungua Pad iliyopo na jina:",
"pad.toolbar.bold.title": "Koozesha (Ctrl+B)",
"pad.toolbar.italic.title": "Mlalo (Ctrl+I)",
"pad.toolbar.underline.title": "Pigia mstari (Ctrl+U)",
"pad.toolbar.strikethrough.title": "Kata (Ctrl+5)",
"pad.toolbar.ol.title": "Orodha iliyopangliwa (Ctrl+Shift+N)",
"pad.toolbar.ul.title": "Orodha isiyopangiliwa (Ctrl+Shift+L)",
"pad.toolbar.indent.title": "Jongeza (TAB)",
"pad.toolbar.unindent.title": "Punguza (Shift+TAB)",
"pad.toolbar.undo.title": "Tengua (Ctrl+Z)",
"pad.toolbar.redo.title": "Fanyaupya (Ctrl+Y)",
"pad.toolbar.clearAuthorship.title": "Futa Rangi za Uandishi (Ctrl+Shift+C)",
"pad.toolbar.import_export.title": "Ingiza/Toa kutoka/kwa muundo wa faili tofauti",
"pad.toolbar.timeslider.title": "Kiburuzawakati",
"pad.toolbar.savedRevision.title": "Hifadhi Mapitio",
"pad.toolbar.settings.title": "Marekebisho",
"pad.toolbar.embed.title": "Shiriki na Pachika pedi hii",
"pad.toolbar.showusers.title": "Onyesha watumiaji kwenye pedi hii",
"pad.colorpicker.save": "Okoa",
"pad.colorpicker.cancel": "Ghairi",
"pad.loading": "Inapakiwa...",
"pad.noCookie": "Kuki haikuweza kupatikana. Tafadhali ruhusu kuki katika kivinjari chako! Kikao na mipangilio yako haitahifadhiwa kati ya ziara. Hii inaweza kuwa ni kutokana na Etherpad kuingizwa katika iFrame katika Vivinjari vingine. Tafadhali hakikisha Etherpad iko kwenye kikoa / kikoa sawa na iFrame ya mzazi",
"pad.permissionDenied": "Huna ruhusa ya kufikia pedi hii",
"pad.settings.padSettings": "Mipangilio ya pedi",
"pad.settings.myView": "Mtazamo Wangu",
"pad.settings.stickychat": "Ongea kila wakati kwenye skrini",
"pad.settings.chatandusers": "Onyesha Gumzo na Watumiaji",
"pad.settings.colorcheck": "Rangi za uandishi",
"pad.settings.linenocheck": "Nambari za laini",
"pad.settings.rtlcheck": "Soma yaliyomo kutoka kulia kwenda kushoto?",
"pad.settings.fontType": "Aina ya herufi:",
"pad.settings.language": "Lugha:",
"pad.settings.about": "Kuhusu",
"pad.settings.poweredBy": "Kinatumia",
"pad.importExport.import_export": "Ingiza / Hamisha",
"pad.importExport.import": "Pakia faili yoyote ya maandishi au hati",
"pad.importExport.importSuccessful": "Imefanikiwa!",
"pad.importExport.export": "Hamisha pedi ya sasa kama:",
"pad.importExport.exportetherpad": "Etherpad",
"pad.importExport.exporthtml": "HTML",
"pad.importExport.exportplain": "Maandishi wazi",
"pad.importExport.exportword": "Neno la Microsoft",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Fungua Fomati ya Hati)",
"pad.importExport.abiword.innerHTML": "Unaweza kuagiza tu kutoka kwa maandishi wazi au fomati za HTML. Kwa vipengee vya hali ya juu zaidi tafadhali <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\"> weka AbiWord au LibreOffice </a>.",
"pad.modals.connected": "Imeunganishwa",
"pad.modals.reconnecting": "Inaunganisha tena pedi yako…",
"pad.modals.forcereconnect": "Lazimisha kuunganisha tena",
"pad.modals.reconnecttimer": "Kujaribu kuungana tena",
"pad.modals.cancel": "Ghairi",
"pad.modals.userdup": "Imefunguliwa kwenye dirisha lingine",
"pad.modals.userdup.explanation": "Pedi hii inaonekana kufunguliwa katika zaidi ya dirisha moja la kivinjari kwenye kompyuta hii.",
"pad.modals.userdup.advice": "Unganisha tena ili utumie dirisha hili badala yake.",
"pad.modals.unauth": "Haijaidhinishwa",
"pad.modals.unauth.explanation": "Ruhusa zako zimebadilika wakati wa kutazama ukurasa huu. Jaribu kuunganisha tena.",
"pad.modals.looping.explanation": "Kuna shida za mawasiliano na seva ya maingiliano.",
"pad.modals.looping.cause": "Labda uliunganisha kupitia firewall isiyokubaliana au wakala.",
"pad.modals.initsocketfail": "Seva haipatikani.",
"pad.modals.initsocketfail.explanation": "Imeshindwa kuunganisha kwenye seva ya usawazishaji.",
"pad.modals.initsocketfail.cause": "Labda hii ni kwa sababu ya shida na kivinjari chako au muunganisho wako wa mtandao.",
"pad.modals.slowcommit.explanation": "Seva haijibu.",
"pad.modals.slowcommit.cause": "Hii inaweza kuwa ni kwa sababu ya shida na muunganisho wa mtandao.",
"pad.modals.badChangeset.explanation": "Hariri uliyoifanya iliainishwa kuwa haramu na seva ya maingiliano.",
"pad.modals.badChangeset.cause": "Hii inaweza kuwa ni kwa sababu ya usanidi mbaya wa seva au tabia zingine zisizotarajiwa. Tafadhali wasiliana na msimamizi wa huduma, ikiwa unahisi hii ni kosa. Jaribu kuunganisha tena ili uendelee kuhariri.",
"pad.modals.corruptPad.explanation": "Pedi unayojaribu kufikia ni mbovu.",
"pad.modals.corruptPad.cause": "Hii inaweza kuwa ni kwa sababu ya usanidi mbaya wa seva au tabia zingine zisizotarajiwa. Tafadhali wasiliana na msimamizi wa huduma.",
"pad.modals.deleted": "Imefutwa.",
"pad.modals.deleted.explanation": "Pedi hii imeondolewa.",
"pad.modals.rateLimited": "Kiwango kidogo.",
"pad.modals.rateLimited.explanation": "Ulituma ujumbe mwingi kwenye pedi hii kwa hivyo ikakukata.",
"pad.modals.rejected.explanation": "Seva ilikataa ujumbe ambao ulitumwa na kivinjari chako.",
"pad.modals.rejected.cause": "Seva inaweza kuwa imesasishwa wakati unatazama pedi, au labda kuna mdudu katika Etherpad. Jaribu kupakia upya ukurasa.",
"pad.modals.disconnected": "Umetenganishwa",
"pad.modals.disconnected.explanation": "Muunganisho wa seva ulipotea",
"pad.modals.disconnected.cause": "Huenda seva haipatikani. Tafadhali mjulishe msimamizi wa huduma ikiwa hii itaendelea kutokea.",
"pad.share": "Shiriki pedi hii",
"pad.share.readonly": "Soma tu",
"pad.share.link": "Kiungo",
"pad.share.emebdcode": "Pachika URL",
"pad.chat": "Ongea",
"pad.chat.title": "Fungua gumzo kwa pedi hii.",
"pad.chat.loadmessages": "Pakia ujumbe zaidi",
"pad.chat.stick.title": "Funga mazungumzo kwenye skrini",
"pad.chat.writeMessage.placeholder": "Andika ujumbe wako hapa",
"timeslider.followContents": "Fuata sasisho za yaliyomo kwenye pedi",
"timeslider.pageTitle": "{{appTitle}} Mpangaji Nyakati",
"timeslider.toolbar.returnbutton": "Rudi kwenye pedi",
"timeslider.toolbar.authors": "Waandishi",
"timeslider.toolbar.authorsList": "Hakuna Waandishi",
"timeslider.toolbar.exportlink.title": "Hamisha",
"timeslider.exportCurrent": "Hamisha toleo la sasa kama:",
"timeslider.version": "Toleo {{version}}",
"timeslider.saved": "Imehifadhiwa {{month}} {{day}}, {{year}}",
"timeslider.playPause": "Uchezaji / Sitisha Yaliyomo ya Pad",
"timeslider.backRevision": "Rudi nyuma kwenye toleo hili",
"timeslider.forwardRevision": "Nenda mbele kwa marekebisho katika Pad hii",
"timeslider.dateformat": "{{month}} / {{day}} / {{year}} {{hours}}: {{minutes}}: {{seconds}}",
"timeslider.month.january": "Januari",
"timeslider.month.february": "Februari",
"timeslider.month.march": "Machi",
"timeslider.month.april": "Aprili",
"timeslider.month.may": "Mei",
"timeslider.month.june": "Juni",
"timeslider.month.july": "Julai",
"timeslider.month.august": "Agosti",
"timeslider.month.september": "Septemba",
"timeslider.month.october": "Oktoba",
"timeslider.month.november": "Novemba",
"timeslider.month.december": "Desemba",
"timeslider.unnamedauthors": "{{num}} haijatajwa jina {[wingi (num) moja: mwandishi, mwingine: waandishi]}",
"pad.savedrevs.marked": "Marekebisho haya sasa yamewekwa alama kama marekebisho yaliyohifadhiwa",
"pad.savedrevs.timeslider": "Unaweza kuona marekebisho yaliyohifadhiwa kwa kutembelea mpangilio wa nyakati",
"pad.userlist.entername": "Ingiza jina lako",
"pad.userlist.unnamed": "bila jina",
"pad.editbar.clearcolors": "Futa rangi za uandishi kwenye hati nzima? Hii haiwezi kutenduliwa",
"pad.impexp.importbutton": "Ingiza Sasa",
"pad.impexp.importing": "Inaleta ...",
"pad.impexp.confirmimport": "Kuingiza faili kutaondoa maandishi ya sasa ya pedi. Je! Una uhakika unataka kuendelea?",
"pad.impexp.convertFailed": "Hatukuweza kuleta faili hii. Tafadhali tumia fomati ya hati tofauti au nakili ubandike mwenyewe",
"pad.impexp.padHasData": "Hatukuweza kuagiza faili hii kwa sababu pedi hii tayari imekuwa na mabadiliko, tafadhali ingiza kwa pedi mpya",
"pad.impexp.uploadFailed": "Upakiaji umeshindwa, tafadhali jaribu tena",
"pad.impexp.importfailed": "Uingizaji haukufaulu",
"pad.impexp.copypaste": "Tafadhali nakili kuweka",
"pad.impexp.exportdisabled": "Kuhamisha kama muundo wa {{type}} kumezimwa. Tafadhali wasiliana na msimamizi wako wa mfumo kwa maelezo.",
"pad.impexp.maxFileSize": "Faili kubwa sana. Wasiliana na msimamizi wa wavuti yako ili kuongeza saizi iliyoruhusiwa ya kuagiza"
}

View file

@ -2,9 +2,43 @@
"@metadata": { "@metadata": {
"authors": [ "authors": [
"Aefgh39622", "Aefgh39622",
"Andibecker",
"Patsagorn Y." "Patsagorn Y."
] ]
}, },
"admin.page-title": "แดชบอร์ดผู้ดูแลระบบ - Etherpad",
"admin_plugins": "ตัวจัดการปลั๊กอิน",
"admin_plugins.available": "ปลั๊กอินที่มีอยู่",
"admin_plugins.available_not-found": "ไม่พบปลั๊กอิน",
"admin_plugins.available_fetching": "กำลังเรียก…",
"admin_plugins.available_install.value": "ติดตั้ง",
"admin_plugins.available_search.placeholder": "ค้นหาปลั๊กอินที่จะติดตั้ง",
"admin_plugins.description": "คำอธิบาย",
"admin_plugins.installed": "ปลั๊กอินที่ติดตั้ง",
"admin_plugins.installed_fetching": "กำลังเรียกปลั๊กอินที่ติดตั้ง…",
"admin_plugins.installed_nothing": "คุณยังไม่ได้ติดตั้งปลั๊กอินใดๆ",
"admin_plugins.installed_uninstall.value": "ถอนการติดตั้ง",
"admin_plugins.last-update": "การปรับปรุงครั้งล่าสุด",
"admin_plugins.name": "ชื่อ",
"admin_plugins.page-title": "ตัวจัดการปลั๊กอิน - Etherpad",
"admin_plugins.version": "เวอร์ชั่น",
"admin_plugins_info": "ข้อมูลการแก้ไขปัญหา",
"admin_plugins_info.hooks": "ติดตั้งตะขอ",
"admin_plugins_info.hooks_client": "ตะขอฝั่งไคลเอ็นต์",
"admin_plugins_info.hooks_server": "ตะขอฝั่งเซิร์ฟเวอร์",
"admin_plugins_info.parts": "ชิ้นส่วนที่ติดตั้ง",
"admin_plugins_info.plugins": "ปลั๊กอินที่ติดตั้ง",
"admin_plugins_info.page-title": "ข้อมูลปลั๊กอิน - Etherpad",
"admin_plugins_info.version": "รุ่น Etherpad",
"admin_plugins_info.version_latest": "เวอร์ชันล่าสุดที่มีอยู่",
"admin_plugins_info.version_number": "หมายเลขเวอร์ชัน",
"admin_settings": "การตั้งค่า",
"admin_settings.current": "การกำหนดค่าปัจจุบัน",
"admin_settings.current_example-devel": "ตัวอย่างเทมเพลตการตั้งค่าการพัฒนา",
"admin_settings.current_example-prod": "ตัวอย่างเทมเพลตการตั้งค่าการผลิต",
"admin_settings.current_restart.value": "รีสตาร์ท Etherpad",
"admin_settings.current_save.value": "บันทึกการตั้งค่า",
"admin_settings.page-title": "การตั้งค่า - Etherpad",
"index.newPad": "สร้างแผ่นจดบันทึกใหม่", "index.newPad": "สร้างแผ่นจดบันทึกใหม่",
"index.createOpenPad": "หรือสร้าง/เปิดแผ่นจดบันทึกที่มีชื่อ:", "index.createOpenPad": "หรือสร้าง/เปิดแผ่นจดบันทึกที่มีชื่อ:",
"index.openPad": "เปิดแพดที่มีอยู่แล้วด้วยชื่อ:", "index.openPad": "เปิดแพดที่มีอยู่แล้วด้วยชื่อ:",
@ -77,6 +111,8 @@
"pad.modals.deleted.explanation": "แผ่นจดบันทึกนี้ได้ถูกลบออกแล้ว", "pad.modals.deleted.explanation": "แผ่นจดบันทึกนี้ได้ถูกลบออกแล้ว",
"pad.modals.rateLimited": "ถึงขีดจำกัด", "pad.modals.rateLimited": "ถึงขีดจำกัด",
"pad.modals.rateLimited.explanation": "คณส่งข้อความถึงแพดนี้มากเกินไปจึงถูกตัดการเชื่อมโดยโปรแกรมอัตโนมัติ", "pad.modals.rateLimited.explanation": "คณส่งข้อความถึงแพดนี้มากเกินไปจึงถูกตัดการเชื่อมโดยโปรแกรมอัตโนมัติ",
"pad.modals.rejected.explanation": "เซิร์ฟเวอร์ปฏิเสธข้อความที่ส่งโดยเบราว์เซอร์ของคุณ",
"pad.modals.rejected.cause": "เซิร์ฟเวอร์อาจได้รับการอัปเดตในขณะที่คุณกำลังดูแพด หรืออาจมีข้อบกพร่องใน Etherpad ลองโหลดหน้านี้ใหม่",
"pad.modals.disconnected": "คุณได้ตัดการเชื่อมต่อแล้ว", "pad.modals.disconnected": "คุณได้ตัดการเชื่อมต่อแล้ว",
"pad.modals.disconnected.explanation": "การเชื่อมต่อกับเซิร์ฟเวอร์ถูกตัด", "pad.modals.disconnected.explanation": "การเชื่อมต่อกับเซิร์ฟเวอร์ถูกตัด",
"pad.modals.disconnected.cause": "เซิร์ฟเวอร์อาจใช้ไม่ได้ชั่วคราว โปรดแจ้งให้ผู้ดูแลการให้บริการทราบถ้าปัญหานี้ยังคงเกิดขึ้น", "pad.modals.disconnected.cause": "เซิร์ฟเวอร์อาจใช้ไม่ได้ชั่วคราว โปรดแจ้งให้ผู้ดูแลการให้บริการทราบถ้าปัญหานี้ยังคงเกิดขึ้น",

View file

@ -44,12 +44,12 @@
"admin_settings.current": "Geçerli yapılandırma", "admin_settings.current": "Geçerli yapılandırma",
"admin_settings.current_example-devel": "Örnek geliştirme ayarları şablonu", "admin_settings.current_example-devel": "Örnek geliştirme ayarları şablonu",
"admin_settings.current_example-prod": "Örnek üretim ayarları şablonu", "admin_settings.current_example-prod": "Örnek üretim ayarları şablonu",
"admin_settings.current_restart.value": "Etherpad'ı sıfırla", "admin_settings.current_restart.value": "Etherpad'i yeniden başlatın",
"admin_settings.current_save.value": "Ayarları Kaydet", "admin_settings.current_save.value": "Ayarları Kaydet",
"admin_settings.page-title": "Ayarlar - Etherpad", "admin_settings.page-title": "Ayarlar - Etherpad",
"index.newPad": "Yeni Bloknot", "index.newPad": "Yeni Bloknot",
"index.createOpenPad": "veya şu adla bir Bloknot oluşturun/açın:", "index.createOpenPad": "veya şu adla bir Bloknot oluşturun/açın:",
"index.openPad": "şu adla varolan bir Pad'iın:", "index.openPad": "şu adla varolan bir Bloknot'uın:",
"pad.toolbar.bold.title": "Kalın (Ctrl+B)", "pad.toolbar.bold.title": "Kalın (Ctrl+B)",
"pad.toolbar.italic.title": "Eğik (Ctrl+I)", "pad.toolbar.italic.title": "Eğik (Ctrl+I)",
"pad.toolbar.underline.title": "Altı Çizili (Ctrl+U)", "pad.toolbar.underline.title": "Altı Çizili (Ctrl+U)",
@ -65,12 +65,12 @@
"pad.toolbar.timeslider.title": "Zaman Çizelgesi", "pad.toolbar.timeslider.title": "Zaman Çizelgesi",
"pad.toolbar.savedRevision.title": "Düzeltmeyi Kaydet", "pad.toolbar.savedRevision.title": "Düzeltmeyi Kaydet",
"pad.toolbar.settings.title": "Ayarlar", "pad.toolbar.settings.title": "Ayarlar",
"pad.toolbar.embed.title": "Bu bloknotu Paylaş ve Göm", "pad.toolbar.embed.title": "Bu Bloknot'u Paylaş ve Göm",
"pad.toolbar.showusers.title": "Kullanıcıları bu bloknotta göster", "pad.toolbar.showusers.title": "Kullanıcıları bu bloknotta göster",
"pad.colorpicker.save": "Kaydet", "pad.colorpicker.save": "Kaydet",
"pad.colorpicker.cancel": "İptal", "pad.colorpicker.cancel": "İptal",
"pad.loading": "Yükleniyor...", "pad.loading": "Yükleniyor...",
"pad.noCookie": "Çerez bulunamadı. Lütfen tarayıcınızda çerezlere izin veriniz! Lütfen tarayıcınızda çerezlere izin verin! Oturumunuz ve ayarlarınız ziyaretler arasında kaydedilmez. Bunun nedeni, Etherpad'in bazı Tarayıcılarda bir iFrame'e dahil edilmiş olması olabilir. Lütfen Etherpad'in üst iFrame ile aynı alt alanda/alanda olduğundan emin olun", "pad.noCookie": "Çerez bulunamadı. Lütfen tarayıcınızda çerezlere izin verin! Oturumunuz ve ayarlarınız ziyaretler arasında kaydedilmez. Bunun nedeni, bazı Tarayıcılarda Etherpad'in bir iFrame'e dahil edilmesi olabilir. Lütfen Etherpad'in üst iFrame ile aynı alt etki alanında/etki alanında olduğundan emin olun.",
"pad.permissionDenied": "Bu bloknota erişmeye izniniz yok", "pad.permissionDenied": "Bu bloknota erişmeye izniniz yok",
"pad.settings.padSettings": "Bloknot Ayarları", "pad.settings.padSettings": "Bloknot Ayarları",
"pad.settings.myView": "Görünümüm", "pad.settings.myView": "Görünümüm",
@ -83,7 +83,7 @@
"pad.settings.fontType.normal": "Olağan", "pad.settings.fontType.normal": "Olağan",
"pad.settings.language": "Dil:", "pad.settings.language": "Dil:",
"pad.settings.about": "Hakkında", "pad.settings.about": "Hakkında",
"pad.settings.poweredBy": "Destekleyen", "pad.settings.poweredBy": "Destekleyen:",
"pad.importExport.import_export": "İçe/Dışa aktar", "pad.importExport.import_export": "İçe/Dışa aktar",
"pad.importExport.import": "Herhangi bir metin dosyası ya da belgesi yükle", "pad.importExport.import": "Herhangi bir metin dosyası ya da belgesi yükle",
"pad.importExport.importSuccessful": "Başarılı!", "pad.importExport.importSuccessful": "Başarılı!",
@ -94,37 +94,37 @@
"pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF", "pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Açık Doküman Biçimi)", "pad.importExport.exportopen": "ODF (Açık Doküman Biçimi)",
"pad.importExport.abiword.innerHTML": "Yalnızca düz metin ya da HTML biçimlerini içe aktarabilirsiniz. Daha fazla gelişmiş içe aktarım özellikleri için lütfen <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">AbiWord veya LibreOffice yükleyin</a>.", "pad.importExport.abiword.innerHTML": "Yalnızca düz metin veya HTML biçimlerinden içe aktarabilirsiniz. Daha gelişmiş içe aktarma özellikleri için lütfen <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">AbiWord veya LibreOffice yükleyin</a> .",
"pad.modals.connected": "Bağlandı.", "pad.modals.connected": "Bağlandı.",
"pad.modals.reconnecting": "Pedinize tekrar bağlanılıyor…", "pad.modals.reconnecting": "Bloknotuza tekrar bağlanılıyor…",
"pad.modals.forcereconnect": "Yeniden bağlanmaya zorla", "pad.modals.forcereconnect": "Yeniden bağlanmaya zorla",
"pad.modals.reconnecttimer": "Yeniden bağlanmaya çalışılıyor", "pad.modals.reconnecttimer": "Yeniden bağlanmaya çalışılıyor",
"pad.modals.cancel": "İptal", "pad.modals.cancel": "İptal",
"pad.modals.userdup": "Başka pencerede açıldı", "pad.modals.userdup": "Başka pencerede açıldı",
"pad.modals.userdup.explanation": "Bu bloknot bu bilgisayarda birden fazla tarayıcı penceresinde açılmış gibi görünüyor.", "pad.modals.userdup.explanation": "Bu bloknot bu bilgisayarda birden fazla tarayıcı penceresinde açılmış gibi görünüyor.",
"pad.modals.userdup.advice": "Bu pencereden kullanmak için yeniden bağlanın.", "pad.modals.userdup.advice": "Bu pencereden kullanmak için yeniden bağlanın.",
"pad.modals.unauth": "Yetkili değil", "pad.modals.unauth": "Yetkilendirilmemiş",
"pad.modals.unauth.explanation": "Bu sayfayı görüntülerken izinleriniz değiştirildi. Tekrar bağlanmayı deneyin.", "pad.modals.unauth.explanation": "Bu sayfayı görüntülerken izinleriniz değiştirildi. Tekrar bağlanmayı deneyin.",
"pad.modals.looping.explanation": "Eşitleme sunucusu ile iletişim sorunları yaşanıyor.", "pad.modals.looping.explanation": "Senkronizasyon sunucusuyla iletişim sorunları yaşanıyor.",
"pad.modals.looping.cause": "Belki de uygun olmayan güvenlik duvarı ya da vekil sunucu (proxy) ile bağlanmaya çalışıyorsunuz.", "pad.modals.looping.cause": "Belki de uygun olmayan güvenlik duvarı ya da vekil sunucu (proxy) ile bağlanmaya çalışıyorsunuz.",
"pad.modals.initsocketfail": "Sunucuya erişilemiyor.", "pad.modals.initsocketfail": "Sunucuya ulaşılamıyor.",
"pad.modals.initsocketfail.explanation": "Eşitleme sunucusuna bağlantı kurulamıyor.", "pad.modals.initsocketfail.explanation": "Senkronizasyon sunucusuna bağlanılamadı.",
"pad.modals.initsocketfail.cause": "Bu sorun muhtemelen, tarayıcınızdan ya da internet bağlantınızdan kaynaklanıyor.", "pad.modals.initsocketfail.cause": "Bu sorun muhtemelen, tarayıcınızdan ya da internet bağlantınızdan kaynaklanıyor.",
"pad.modals.slowcommit.explanation": "Sunucu yanıt vermiyor.", "pad.modals.slowcommit.explanation": "Sunucu yanıt vermiyor.",
"pad.modals.slowcommit.cause": "Bu hata ağ bağlantısı sebebiyle olabilir.", "pad.modals.slowcommit.cause": "Bu durum, ağ bağlantısıyla ilgili sorunlardan kaynaklanıyor olabilir.",
"pad.modals.badChangeset.explanation": "Yaptığınız bir düzenleme eşitleme sunucusu tarafından kullanışsız/kural dışı olarak sınıflandırıldı.", "pad.modals.badChangeset.explanation": "Yaptığınız bir düzenleme, senkronizasyon sunucusu tarafından yasa dışı olarak sınıflandırıldı.",
"pad.modals.badChangeset.cause": "Bunun nedeni, yanlış bir sunucu yapılandırması veya beklenmeyen başka bir davranış olabilir. Bunun bir hata olduğunu düşünüyorsanız lütfen servis yöneticisine başvurun. Düzenlemeye devam etmek için yeniden bağlanmayı deneyin.", "pad.modals.badChangeset.cause": "Bunun nedeni yanlış bir sunucu yapılandırması veya başka bir beklenmeyen davranış olabilir. Bunun bir hata olduğunu düşünüyorsanız lütfen servis yöneticisi ile iletişime geçin. Düzenlemeye devam etmek için yeniden bağlanmayı deneyin.",
"pad.modals.corruptPad.explanation": "Erişmeye çalıştığınız bloknot bozuk.", "pad.modals.corruptPad.explanation": "Erişmeye çalıştığınız bloknot bozuk.",
"pad.modals.corruptPad.cause": "Bunun nedeni yanlış bir sunucu yapılandırması veya beklenmeyen başka bir davranış olabilir. Lütfen servis yöneticisine başvurun.", "pad.modals.corruptPad.cause": "Bunun nedeni yanlış bir sunucu yapılandırması veya başka bir beklenmeyen davranış olabilir. Lütfen servis yöneticisine başvurun.",
"pad.modals.deleted": "Silindi.", "pad.modals.deleted": "Silindi.",
"pad.modals.deleted.explanation": "Bu bloknot kaldırılmış.", "pad.modals.deleted.explanation": "Bu bloknot kaldırılmış.",
"pad.modals.rateLimited": "Oran Sınırlı.", "pad.modals.rateLimited": "Oran Sınırlı.",
"pad.modals.rateLimited.explanation": "Bu pad'e çok fazla mesaj gönderdiniz, böylece bağlantı kesildi.", "pad.modals.rateLimited.explanation": "Bu bloknota çok fazla mesaj gönderdiğiniz için bağlantı kesildi.",
"pad.modals.rejected.explanation": "Sunucu, tarayıcınız tarafından gönderilen bir mesajı reddetti.", "pad.modals.rejected.explanation": "Sunucu, tarayıcınız tarafından gönderilen bir mesajı reddetti.",
"pad.modals.rejected.cause": "Siz pedi görüntülerken sunucu güncellenmiş olabilir veya Etherpad'de bir hata olabilir. Sayfayı yeniden yüklemeyi deneyin.", "pad.modals.rejected.cause": "Bloknotu görüntülerken sunucu güncellenmiş olabilir veya Etherpad'de bir hata olabilir. Sayfayı yeniden yüklemeyi deneyin.",
"pad.modals.disconnected": "Bağlantınız koptu.", "pad.modals.disconnected": "Bağlantınız kesildi.",
"pad.modals.disconnected.explanation": "Sunucu bağlantısı kaybedildi", "pad.modals.disconnected.explanation": "Sunucuyla bağlantı kesildi",
"pad.modals.disconnected.cause": "Sunucu kullanılamıyor olabilir. Bunun devam etmesi durumunda servis yöneticisine bildirin.", "pad.modals.disconnected.cause": "Sunucu kullanılamıyor olabilir. Böyle devam ederse lütfen hizmet yöneticisine bildirin.",
"pad.share": "Bu bloknotu paylaş", "pad.share": "Bu bloknotu paylaş",
"pad.share.readonly": "Yalnızca oku", "pad.share.readonly": "Yalnızca oku",
"pad.share.link": "Bağlantı", "pad.share.link": "Bağlantı",
@ -133,20 +133,20 @@
"pad.chat.title": "Bu bloknot için sohbeti açın.", "pad.chat.title": "Bu bloknot için sohbeti açın.",
"pad.chat.loadmessages": "Daha fazla mesaj yükle", "pad.chat.loadmessages": "Daha fazla mesaj yükle",
"pad.chat.stick.title": "Sohbeti ekrana yapıştır", "pad.chat.stick.title": "Sohbeti ekrana yapıştır",
"pad.chat.writeMessage.placeholder": "Mesajını buraya yaz", "pad.chat.writeMessage.placeholder": "Mesajınızı buraya yazın",
"timeslider.followContents": "Pad içerik güncellemelerini takip edin", "timeslider.followContents": "Bloknot içerik güncellemelerini takip edin",
"timeslider.pageTitle": "{{appTitle}} Zaman Çizelgesi", "timeslider.pageTitle": "{{appTitle}} Zaman Çizelgesi",
"timeslider.toolbar.returnbutton": "Bloknota geri dön", "timeslider.toolbar.returnbutton": "Bloknota geri dön",
"timeslider.toolbar.authors": "Yazarlar:", "timeslider.toolbar.authors": "Yazarlar:",
"timeslider.toolbar.authorsList": "Yazar Yok", "timeslider.toolbar.authorsList": "Yazar Yok",
"timeslider.toolbar.exportlink.title": "Dışa aktar", "timeslider.toolbar.exportlink.title": "Dışa aktar",
"timeslider.exportCurrent": "Mevcut sürümü şu olarak dışa aktar:", "timeslider.exportCurrent": "Geçerli sürümü şu şekilde dışa aktar:",
"timeslider.version": "{{version}} sürümü", "timeslider.version": "Sürüm {{version}}",
"timeslider.saved": "{{day}} {{month}} {{year}} tarihinde kaydedildi", "timeslider.saved": "{{day}} {{month}} {{year}} tarihinde kaydedildi",
"timeslider.playPause": "Bloknot İçeriğini Oynat / Durdur", "timeslider.playPause": "Bloknot İçeriğini Oynat / Durdur",
"timeslider.backRevision": "Bu bloknottaki bir revizyona geri git", "timeslider.backRevision": "Bu bloknottaki bir sürüme geri dön",
"timeslider.forwardRevision": "Bu bloknatta sonraki revizyona git", "timeslider.forwardRevision": "Bu bloknatta sonraki sürüme git",
"timeslider.dateformat": "{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", "timeslider.dateformat": "{{day}}.{{month}}.{{year}} {{hours}}.{{minutes}}.{{seconds}}",
"timeslider.month.january": "Ocak", "timeslider.month.january": "Ocak",
"timeslider.month.february": "Şubat", "timeslider.month.february": "Şubat",
"timeslider.month.march": "Mart", "timeslider.month.march": "Mart",
@ -160,19 +160,19 @@
"timeslider.month.november": "Kasım", "timeslider.month.november": "Kasım",
"timeslider.month.december": "Aralık", "timeslider.month.december": "Aralık",
"timeslider.unnamedauthors": "{{num}} isimsiz {[plural(num) one: yazar, other: yazar ]}", "timeslider.unnamedauthors": "{{num}} isimsiz {[plural(num) one: yazar, other: yazar ]}",
"pad.savedrevs.marked": "Bu düzenleme artık kayıtlı bir düzeltme olarak işaretlendi", "pad.savedrevs.marked": "Bu sürüm, artık kaydedilmiş bir sürüm olarak işaretlendi.",
"pad.savedrevs.timeslider": "Zaman kaydırıcısını ziyaret ederek kaydedilen revizyonları görebilirsiniz", "pad.savedrevs.timeslider": "Kaydedilmiş sürümleri, zaman kaydırıcısını ziyaret ederek görebilirsiniz.",
"pad.userlist.entername": "Adınızı girin", "pad.userlist.entername": "Adınızı girin",
"pad.userlist.unnamed": "isimsiz", "pad.userlist.unnamed": "isimsiz",
"pad.editbar.clearcolors": "Bütün belgedeki yazarlık renkleri silinsin mi? Bu işlem geri alınamaz", "pad.editbar.clearcolors": "Bütün belgedeki yazarlık renkleri silinsin mi? Bu işlem geri alınamaz.",
"pad.impexp.importbutton": "Şimdi İçe Aktar", "pad.impexp.importbutton": "Şimdi İçe Aktar",
"pad.impexp.importing": "İçe aktarıyor...", "pad.impexp.importing": "İçe aktarılıyor...",
"pad.impexp.confirmimport": "Bir dosya içe aktarılırken bloknotun mevcut metninin üzerine yazdırılır. Devam etmek istediğinizden emin misiniz?", "pad.impexp.confirmimport": "Bir dosyanın içe aktarılması, bloknotun mevcut metninin üzerine yazacaktır. Devam etmek istediğinizden emin misiniz?",
"pad.impexp.convertFailed": "Bu dosyayı içe aktarmak mümkün değil. Lütfen farklı bir belge biçimi kullanın ya da elle kopyala yapıştır yapın", "pad.impexp.convertFailed": "Bu dosyayı içe aktaramadık. Lütfen farklı bir belge biçimi kullanın veya elle kopyalayıp yapıştırın",
"pad.impexp.padHasData": "Bu Pad'in zaten değişiklikleri olduğu için bu dosyayı içe aktaramadık, lütfen yeni bir bloknota aktarın", "pad.impexp.padHasData": "Bu bloknotta zaten değişiklikler olduğu için bu dosyayı içe aktaramadık, lütfen yeni bir bloknota aktarın.",
"pad.impexp.uploadFailed": "Yükleme başarısız, lütfen tekrar deneyin", "pad.impexp.uploadFailed": "Yükleme başarısız oldu, lütfen tekrar deneyin.",
"pad.impexp.importfailed": "İçe aktarım başarısız oldu", "pad.impexp.importfailed": "İçe aktarılamadı",
"pad.impexp.copypaste": "Lütfen kopyala yapıştır yapın", "pad.impexp.copypaste": "Lütfen kopyala yapıştır yapın",
"pad.impexp.exportdisabled": "{{type}} biçimiyle dışa aktarma devre dışı bırakıldı. Ayrıntılar için sistem yöneticinizle iletişime geçiniz.", "pad.impexp.exportdisabled": "{{type}} biçiminde dışa aktarma devre dışı bırakıldı. Ayrıntılar için lütfen sistem yöneticinize başvurun.",
"pad.impexp.maxFileSize": "Dosya çok büyük. İçe aktarma için izin verilen dosya boyutunu artırmak için site yöneticinize başvurun" "pad.impexp.maxFileSize": "Dosya çok büyük. İçe aktarma için izin verilen dosya boyutunu artırmak için site yöneticinize başvurun"
} }

View file

@ -4,6 +4,7 @@
"Andriykopanytsia", "Andriykopanytsia",
"Base", "Base",
"Bunyk", "Bunyk",
"DDPAT",
"Lxlalexlxl", "Lxlalexlxl",
"Movses", "Movses",
"Olvin", "Olvin",
@ -31,6 +32,10 @@
"admin_plugins.page-title": "Менеджер плагінів — Etherpad", "admin_plugins.page-title": "Менеджер плагінів — Etherpad",
"admin_plugins.version": "Версія", "admin_plugins.version": "Версія",
"admin_plugins_info": "Інформація щодо виправлення неполадок", "admin_plugins_info": "Інформація щодо виправлення неполадок",
"admin_plugins_info.hooks": "Встановлені гачки",
"admin_plugins_info.hooks_client": "Гачки на стороні клієнта",
"admin_plugins_info.hooks_server": "Серверні гачки",
"admin_plugins_info.parts": "Встановлені деталі",
"admin_plugins_info.plugins": "Встановлені плагіни", "admin_plugins_info.plugins": "Встановлені плагіни",
"admin_plugins_info.page-title": "Інформація про плагіни — Etherpad", "admin_plugins_info.page-title": "Інформація про плагіни — Etherpad",
"admin_plugins_info.version": "Версія Etherpad", "admin_plugins_info.version": "Версія Etherpad",
@ -38,6 +43,8 @@
"admin_plugins_info.version_number": "Номер версії", "admin_plugins_info.version_number": "Номер версії",
"admin_settings": "Налаштування", "admin_settings": "Налаштування",
"admin_settings.current": "Поточна конфігурація", "admin_settings.current": "Поточна конфігурація",
"admin_settings.current_example-devel": "Приклад шаблону налаштувань розробки",
"admin_settings.current_example-prod": "Приклад шаблону налаштувань виробництва",
"admin_settings.current_restart.value": "Перезапустити Etherpad", "admin_settings.current_restart.value": "Перезапустити Etherpad",
"admin_settings.current_save.value": "Зберегти налаштування", "admin_settings.current_save.value": "Зберегти налаштування",
"admin_settings.page-title": "Налаштування — Etherpad", "admin_settings.page-title": "Налаштування — Etherpad",
@ -64,10 +71,10 @@
"pad.colorpicker.save": "Зберегти", "pad.colorpicker.save": "Зберегти",
"pad.colorpicker.cancel": "Скасувати", "pad.colorpicker.cancel": "Скасувати",
"pad.loading": "Завантаження…", "pad.loading": "Завантаження…",
"pad.noCookie": "Реп'яшки не знайдено. Будь ласка, увімкніть реп'яшки у вашому браузері! Ваша сесія та налаштування не зберігатимуться між візитами. Це може спричинятися тим, що Etherpad у деяких браузерах включений через iFrame. Будь ласка, переконайтеся, що iFrame міститься на тому ж піддомені/домені, що й батьківський iFrame", "pad.noCookie": "Cookie не знайдено. Будь ласка, увімкніть cookie у вашому браузері! Ваша сесія та налаштування не зберігатимуться між візитами. Це може спричинятися тим, що Etherpad у деяких браузерах включений через iFrame. Будь ласка, переконайтеся, що iFrame міститься на тому ж піддомені/домені, що й батьківський iFrame",
"pad.permissionDenied": "У Вас немає дозволу для доступу до цього документа", "pad.permissionDenied": "У Вас немає дозволу для доступу до цього документа",
"pad.settings.padSettings": "Налаштування документа", "pad.settings.padSettings": "Налаштування документа",
"pad.settings.myView": "Мій Вигляд", "pad.settings.myView": "Мій погляд",
"pad.settings.stickychat": "Завжди відображувати чат", "pad.settings.stickychat": "Завжди відображувати чат",
"pad.settings.chatandusers": "Показати чат і користувачів", "pad.settings.chatandusers": "Показати чат і користувачів",
"pad.settings.colorcheck": "Кольори авторства", "pad.settings.colorcheck": "Кольори авторства",
@ -98,7 +105,7 @@
"pad.modals.userdup.explanation": "Документ, можливо, відкрито більш ніж в одному вікні браузера на цьому комп'ютері.", "pad.modals.userdup.explanation": "Документ, можливо, відкрито більш ніж в одному вікні браузера на цьому комп'ютері.",
"pad.modals.userdup.advice": "Перепідключитись використовуючи це вікно.", "pad.modals.userdup.advice": "Перепідключитись використовуючи це вікно.",
"pad.modals.unauth": "Не авторизовано", "pad.modals.unauth": "Не авторизовано",
"pad.modals.unauth.explanation": "Ваші права було змінено під час перегляду цієї сторінк. Спробуйте перепідключитись.", "pad.modals.unauth.explanation": "Ваші права було змінено під час перегляду цієї сторінки. Спробуйте відновити зв’язок.",
"pad.modals.looping.explanation": "Проблеми зв'єзку з сервером синхронізації.", "pad.modals.looping.explanation": "Проблеми зв'єзку з сервером синхронізації.",
"pad.modals.looping.cause": "Можливо, підключились через несумісний брандмауер або проксі-сервер.", "pad.modals.looping.cause": "Можливо, підключились через несумісний брандмауер або проксі-сервер.",
"pad.modals.initsocketfail": "Сервер недоступний.", "pad.modals.initsocketfail": "Сервер недоступний.",
@ -115,7 +122,7 @@
"pad.modals.rateLimited": "Швидкість обмежено.", "pad.modals.rateLimited": "Швидкість обмежено.",
"pad.modals.rateLimited.explanation": "Ви надіслали надто багато повідомлень у цей документ, тому він вас від'єднав.", "pad.modals.rateLimited.explanation": "Ви надіслали надто багато повідомлень у цей документ, тому він вас від'єднав.",
"pad.modals.rejected.explanation": "Сервер відхилив повідомлення, надіслане вашим браузером.", "pad.modals.rejected.explanation": "Сервер відхилив повідомлення, надіслане вашим браузером.",
"pad.modals.rejected.cause": "Сервер міг оновитися, поки ви переглядали документ, а може це баг в Etherpad'і. Спробуйте перезавантажити сторінку.", "pad.modals.rejected.cause": "Сервер міг оновитися, поки ви переглядали документ, а може це помилка у Etherpad'і. Спробуйте перезавантажити сторінку.",
"pad.modals.disconnected": "Вас було від'єднано.", "pad.modals.disconnected": "Вас було від'єднано.",
"pad.modals.disconnected.explanation": "З'єднання з сервером втрачено", "pad.modals.disconnected.explanation": "З'єднання з сервером втрачено",
"pad.modals.disconnected.cause": "Сервер, можливо, недоступний. Будь ласка, повідомте адміністратора служби, якщо це повторюватиметься.", "pad.modals.disconnected.cause": "Сервер, можливо, недоступний. Будь ласка, повідомте адміністратора служби, якщо це повторюватиметься.",
@ -137,7 +144,7 @@
"timeslider.exportCurrent": "Експортувати поточну версію як:", "timeslider.exportCurrent": "Експортувати поточну версію як:",
"timeslider.version": "Версія {{version}}", "timeslider.version": "Версія {{version}}",
"timeslider.saved": "Збережено {{month}} {{day}}, {{year}}", "timeslider.saved": "Збережено {{month}} {{day}}, {{year}}",
"timeslider.playPause": "Відтворення / Пауза Панель Зміст", "timeslider.playPause": "Вміст панелі відтворення/паузи",
"timeslider.backRevision": "Переглянути попередню ревізію цієї панелі", "timeslider.backRevision": "Переглянути попередню ревізію цієї панелі",
"timeslider.forwardRevision": "Переглянути наступну ревізію цієї панелі", "timeslider.forwardRevision": "Переглянути наступну ревізію цієї панелі",
"timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", "timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}",
@ -155,8 +162,8 @@
"timeslider.month.december": "Грудень", "timeslider.month.december": "Грудень",
"timeslider.unnamedauthors": "{{num}} {[plural(num) one: безіменний автор, few: безіменні автори, many: безіменних авторів, other: безіменних авторів]}", "timeslider.unnamedauthors": "{{num}} {[plural(num) one: безіменний автор, few: безіменні автори, many: безіменних авторів, other: безіменних авторів]}",
"pad.savedrevs.marked": "Цю версію помічено збереженою версією", "pad.savedrevs.marked": "Цю версію помічено збереженою версією",
"pad.savedrevs.timeslider": "Ви можете побачити збережені ревізії, відвідавши \"Слайдер Змін Ревізій\"", "pad.savedrevs.timeslider": "Ви можете побачити збережені ревізії, відвідавши «Слайдер Змін Ревізій»",
"pad.userlist.entername": "Введіть Ваше ім'я", "pad.userlist.entername": "Введіть ваше ім'я",
"pad.userlist.unnamed": "безіменний", "pad.userlist.unnamed": "безіменний",
"pad.editbar.clearcolors": "Очистити кольори у всьому документі? Це не можна буде відкотити", "pad.editbar.clearcolors": "Очистити кольори у всьому документі? Це не можна буде відкотити",
"pad.impexp.importbutton": "Імпортувати зараз", "pad.impexp.importbutton": "Імпортувати зараз",

View file

@ -3,6 +3,7 @@
"authors": [ "authors": [
"94rain", "94rain",
"Dimension", "Dimension",
"GuoPC",
"Hydra", "Hydra",
"Hzy980512", "Hzy980512",
"JuneAugust", "JuneAugust",
@ -67,7 +68,7 @@
"pad.importExport.exportopen": "ODF开放文档格式", "pad.importExport.exportopen": "ODF开放文档格式",
"pad.importExport.abiword.innerHTML": "您只可以导入纯文本或HTML格式。要获取更高级的导入功能请<a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">安装 AbiWord 或是 LibreOffice</a>。", "pad.importExport.abiword.innerHTML": "您只可以导入纯文本或HTML格式。要获取更高级的导入功能请<a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">安装 AbiWord 或是 LibreOffice</a>。",
"pad.modals.connected": "已连接。", "pad.modals.connected": "已连接。",
"pad.modals.reconnecting": "重新连接到您的记事本...", "pad.modals.reconnecting": "重新连接到您的记事本",
"pad.modals.forcereconnect": "强制重新连接", "pad.modals.forcereconnect": "强制重新连接",
"pad.modals.reconnecttimer": "尝试重新连入", "pad.modals.reconnecttimer": "尝试重新连入",
"pad.modals.cancel": "取消", "pad.modals.cancel": "取消",

View file

@ -20,6 +20,7 @@
*/ */
const Changeset = require('../../static/js/Changeset'); const Changeset = require('../../static/js/Changeset');
const ChatMessage = require('../../static/js/ChatMessage');
const CustomError = require('../utils/customError'); const CustomError = require('../utils/customError');
const padManager = require('./PadManager'); const padManager = require('./PadManager');
const padMessageHandler = require('../handler/PadMessageHandler'); const padMessageHandler = require('../handler/PadMessageHandler');
@ -364,7 +365,7 @@ exports.appendChatMessage = async (padID, text, authorID, time) => {
// @TODO - missing getPadSafe() call ? // @TODO - missing getPadSafe() call ?
// save chat message to database and send message to all connected clients // save chat message to database and send message to all connected clients
await padMessageHandler.sendChatMessageToPadClients(time, authorID, text, padID); await padMessageHandler.sendChatMessageToPadClients(new ChatMessage(text, authorID, time), padID);
}; };
/* *************** /* ***************

View file

@ -5,6 +5,7 @@
const Changeset = require('../../static/js/Changeset'); const Changeset = require('../../static/js/Changeset');
const ChatMessage = require('../../static/js/ChatMessage');
const AttributePool = require('../../static/js/AttributePool'); const AttributePool = require('../../static/js/AttributePool');
const db = require('./DB'); const db = require('./DB');
const settings = require('../utils/Settings'); const settings = require('../utils/Settings');
@ -258,7 +259,7 @@ Pad.prototype.setText = async function (newText) {
} }
// append the changeset // append the changeset
await this.appendRevision(changeset); if (newText !== oldText) await this.appendRevision(changeset);
}; };
Pad.prototype.appendText = async function (newText) { Pad.prototype.appendText = async function (newText) {
@ -274,53 +275,61 @@ Pad.prototype.appendText = async function (newText) {
await this.appendRevision(changeset); await this.appendRevision(changeset);
}; };
Pad.prototype.appendChatMessage = async function (text, userId, time) { /**
* Adds a chat message to the pad, including saving it to the database.
*
* @param {(ChatMessage|string)} msgOrText - Either a chat message object (recommended) or a string
* containing the raw text of the user's chat message (deprecated).
* @param {?string} [authorId] - The user's author ID. Deprecated; use `msgOrText.authorId` instead.
* @param {?number} [time] - Message timestamp (milliseconds since epoch). Deprecated; use
* `msgOrText.time` instead.
*/
Pad.prototype.appendChatMessage = async function (msgOrText, authorId = null, time = null) {
const msg =
msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time);
this.chatHead++; this.chatHead++;
// save the chat entry in the database
await Promise.all([ await Promise.all([
db.set(`pad:${this.id}:chat:${this.chatHead}`, {text, userId, time}), // Don't save the display name in the database because the user can change it at any time. The
// `displayName` property will be populated with the current value when the message is read from
// the database.
db.set(`pad:${this.id}:chat:${this.chatHead}`, {...msg, displayName: undefined}),
this.saveToDatabase(), this.saveToDatabase(),
]); ]);
}; };
/**
* @param {number} entryNum - ID of the desired chat message.
* @returns {?ChatMessage}
*/
Pad.prototype.getChatMessage = async function (entryNum) { Pad.prototype.getChatMessage = async function (entryNum) {
// get the chat entry
const entry = await db.get(`pad:${this.id}:chat:${entryNum}`); const entry = await db.get(`pad:${this.id}:chat:${entryNum}`);
if (entry == null) return null;
// get the authorName if the entry exists const message = ChatMessage.fromObject(entry);
if (entry != null) { message.displayName = await authorManager.getAuthorName(message.authorId);
entry.userName = await authorManager.getAuthorName(entry.userId); return message;
}
return entry;
}; };
/**
* @param {number} start - ID of the first desired chat message.
* @param {number} end - ID of the last desired chat message.
* @returns {ChatMessage[]} Any existing messages with IDs between `start` (inclusive) and `end`
* (inclusive), in order. Note: `start` and `end` form a closed interval, not a half-open
* interval as is typical in code.
*/
Pad.prototype.getChatMessages = async function (start, end) { Pad.prototype.getChatMessages = async function (start, end) {
// collect the numbers of chat entries and in which order we need them const entries = await Promise.all(
const neededEntries = []; [...Array(end + 1 - start).keys()].map((i) => this.getChatMessage(start + i)));
for (let order = 0, entryNum = start; entryNum <= end; ++order, ++entryNum) {
neededEntries.push({entryNum, order});
}
// get all entries out of the database
const entries = [];
await Promise.all(
neededEntries.map((entryObject) => this.getChatMessage(entryObject.entryNum).then((entry) => {
entries[entryObject.order] = entry;
})));
// sort out broken chat entries // sort out broken chat entries
// it looks like in happened in the past that the chat head was // it looks like in happened in the past that the chat head was
// incremented, but the chat message wasn't added // incremented, but the chat message wasn't added
const cleanedEntries = entries.filter((entry) => { return entries.filter((entry) => {
const pass = (entry != null); const pass = (entry != null);
if (!pass) { if (!pass) {
console.warn(`WARNING: Found broken chat entry in pad ${this.id}`); console.warn(`WARNING: Found broken chat entry in pad ${this.id}`);
} }
return pass; return pass;
}); });
return cleanedEntries;
}; };
Pad.prototype.init = async function (text) { Pad.prototype.init = async function (text) {
@ -490,7 +499,6 @@ Pad.prototype.copyPadWithoutHistory = async function (destinationID, force) {
// based on Changeset.makeSplice // based on Changeset.makeSplice
const assem = Changeset.smartOpAssembler(); const assem = Changeset.smartOpAssembler();
assem.appendOpWithText('=', '');
Changeset.appendATextToAssembler(oldAText, assem); Changeset.appendATextToAssembler(oldAText, assem);
assem.endDocument(); assem.endDocument();

View file

@ -255,7 +255,7 @@ const listSessionsWithDBKey = async (dbkey) => {
const sessionInfo = await exports.getSessionInfo(sessionID); const sessionInfo = await exports.getSessionInfo(sessionID);
sessions[sessionID] = sessionInfo; sessions[sessionID] = sessionInfo;
} catch (err) { } catch (err) {
if (err === 'apierror: sessionID does not exist') { if (err.name === 'apierror') {
console.warn(`Found bad session ${sessionID} in ${dbkey}`); console.warn(`Found bad session ${sessionID} in ${dbkey}`);
sessions[sessionID] = null; sessions[sessionID] = null;
} else { } else {

View file

@ -56,14 +56,14 @@ exports.doExport = async (req, res, padId, readOnlyId, type) => {
// if this is a plain text export, we can do this directly // if this is a plain text export, we can do this directly
// We have to over engineer this because tabs are stored as attributes and not plain text // We have to over engineer this because tabs are stored as attributes and not plain text
if (type === 'etherpad') { if (type === 'etherpad') {
const pad = await exportEtherpad.getPadRaw(padId); const pad = await exportEtherpad.getPadRaw(padId, readOnlyId);
res.send(pad); res.send(pad);
} else if (type === 'txt') { } else if (type === 'txt') {
const txt = await exporttxt.getPadTXTDocument(padId, req.params.rev); const txt = await exporttxt.getPadTXTDocument(padId, req.params.rev);
res.send(txt); res.send(txt);
} else { } else {
// render the html document // render the html document
let html = await exporthtml.getPadHTMLDocument(padId, req.params.rev); let html = await exporthtml.getPadHTMLDocument(padId, req.params.rev, readOnlyId);
// decide what to do with the html export // decide what to do with the html export

View file

@ -142,11 +142,8 @@ const doImport = async (req, res, padId) => {
} }
const destFile = path.join(tmpDirectory, `etherpad_import_${randNum}.${exportExtension}`); const destFile = path.join(tmpDirectory, `etherpad_import_${randNum}.${exportExtension}`);
const importHandledByPlugin =
// Logic for allowing external Import Plugins (await hooks.aCallAll('import', {srcFile, destFile, fileEnding, padId})).some((x) => x);
const result = await hooks.aCallAll('import', {srcFile, destFile, fileEnding});
const importHandledByPlugin = (result.length > 0); // This feels hacky and wrong..
const fileIsEtherpad = (fileEnding === '.etherpad'); const fileIsEtherpad = (fileEnding === '.etherpad');
const fileIsHTML = (fileEnding === '.html' || fileEnding === '.htm'); const fileIsHTML = (fileEnding === '.html' || fileEnding === '.htm');
const fileIsTXT = (fileEnding === '.txt'); const fileIsTXT = (fileEnding === '.txt');
@ -235,8 +232,8 @@ const doImport = async (req, res, padId) => {
pad = await padManager.getPad(padId); pad = await padManager.getPad(padId);
padManager.unloadPad(padId); padManager.unloadPad(padId);
// direct Database Access means a pad user should perform a switchToPad // Direct database access means a pad user should reload the pad and not attempt to receive
// and not attempt to receive updated pad data // updated pad data.
if (directDatabaseAccess) return true; if (directDatabaseAccess) return true;
// tell clients to update // tell clients to update

View file

@ -21,6 +21,7 @@
const padManager = require('../db/PadManager'); const padManager = require('../db/PadManager');
const Changeset = require('../../static/js/Changeset'); const Changeset = require('../../static/js/Changeset');
const ChatMessage = require('../../static/js/ChatMessage');
const AttributePool = require('../../static/js/AttributePool'); const AttributePool = require('../../static/js/AttributePool');
const AttributeManager = require('../../static/js/AttributeManager'); const AttributeManager = require('../../static/js/AttributeManager');
const authorManager = require('../db/AuthorManager'); const authorManager = require('../db/AuthorManager');
@ -33,14 +34,15 @@ const messageLogger = log4js.getLogger('message');
const accessLogger = log4js.getLogger('access'); const accessLogger = log4js.getLogger('access');
const _ = require('underscore'); const _ = require('underscore');
const hooks = require('../../static/js/pluginfw/hooks.js'); const hooks = require('../../static/js/pluginfw/hooks.js');
const channels = require('channels');
const stats = require('../stats'); const stats = require('../stats');
const assert = require('assert').strict; const assert = require('assert').strict;
const nodeify = require('nodeify');
const {RateLimiterMemory} = require('rate-limiter-flexible'); const {RateLimiterMemory} = require('rate-limiter-flexible');
const webaccess = require('../hooks/express/webaccess'); const webaccess = require('../hooks/express/webaccess');
let rateLimiter; let rateLimiter;
let socketio = null;
hooks.deprecationNotices.clientReady = 'use the userJoin hook instead';
exports.socketio = () => { exports.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
@ -63,14 +65,14 @@ exports.socketio = () => {
* - token: User-supplied token. * - token: User-supplied token.
* - author: The user's author ID. * - author: The user's author ID.
* - padId: The real (not read-only) ID of the pad. * - padId: The real (not read-only) ID of the pad.
* - readonlyPadId: The read-only ID of the pad. * - readOnlyPadId: The read-only ID of the pad.
* - 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 = {}; const sessioninfos = {};
exports.sessioninfos = sessioninfos; exports.sessioninfos = sessioninfos;
stats.gauge('totalUsers', () => Object.keys(socketio.sockets.sockets).length); stats.gauge('totalUsers', () => socketio ? Object.keys(socketio.sockets.sockets).length : 0);
stats.gauge('activePads', () => { stats.gauge('activePads', () => {
const padIds = new Set(); const padIds = new Set();
for (const {padId} of Object.values(sessioninfos)) { for (const {padId} of Object.values(sessioninfos)) {
@ -81,16 +83,43 @@ stats.gauge('activePads', () => {
}); });
/** /**
* A changeset queue per pad that is processed by handleUserChanges() * Processes one task at a time per channel.
*/ */
const padChannels = new channels.channels( class Channels {
({socket, message}, callback) => nodeify(handleUserChanges(socket, message), callback) /**
); * @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.
*/
constructor(exec = (ch, task) => task(ch)) {
this._exec = exec;
this._promiseChains = new Map();
}
/**
* Schedules a task for execution. The task will be executed once all previously enqueued tasks
* for the named channel have completed.
*
* @param {any} ch - Identifies the channel.
* @param {any} task - The task to give to the executor.
* @returns {Promise<any>} The value returned by the executor.
*/
async enqueue(ch, task) {
const p = (this._promiseChains.get(ch) || Promise.resolve()).then(() => this._exec(ch, task));
const pc = p
.catch(() => {}) // Prevent rejections from halting the queue.
.then(() => {
// Clean up this._promiseChains if there are no more tasks for the channel.
if (this._promiseChains.get(ch) === pc) this._promiseChains.delete(ch);
});
this._promiseChains.set(ch, pc);
return await p;
}
}
/** /**
* Saves the Socket class we need to send and receive data from the client * A changeset queue per pad that is processed by handleUserChanges()
*/ */
let socketio; const padChannels = new Channels((ch, {socket, message}) => handleUserChanges(socket, message));
/** /**
* This Method is called by server.js to tell the message handler on which socket it should send * This Method is called by server.js to tell the message handler on which socket it should send
@ -130,45 +159,35 @@ exports.kickSessionsFromPad = (padID) => {
*/ */
exports.handleDisconnect = async (socket) => { exports.handleDisconnect = async (socket) => {
stats.meter('disconnects').mark(); stats.meter('disconnects').mark();
// save the padname of this session
const session = sessioninfos[socket.id]; const session = sessioninfos[socket.id];
// if this connection was already etablished with a handshake,
// send a disconnect message to the others
if (session && session.author) {
const {session: {user} = {}} = socket.client.request;
accessLogger.info(`${'[LEAVE]' +
` pad:${session.padId}` +
` socket:${socket.id}` +
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
` authorID:${session.author}`}${
(user && user.username) ? ` username:${user.username}` : ''}`);
// get the author color out of the db
const color = await authorManager.getAuthorColorId(session.author);
// prepare the notification for the other users on the pad, that this user left
const messageToTheOtherUsers = {
type: 'COLLABROOM',
data: {
type: 'USER_LEAVE',
userInfo: {
colorId: color,
userId: session.author,
},
},
};
// Go through all user that are still on the pad, and send them the USER_LEAVE message
socket.broadcast.to(session.padId).json.send(messageToTheOtherUsers);
// Allow plugins to hook into users leaving the pad
hooks.callAll('userLeave', session);
}
// Delete the sessioninfos entrys of this session
delete sessioninfos[socket.id]; delete sessioninfos[socket.id];
// session.padId can be nullish if the user disconnects before sending CLIENT_READY.
if (!session || !session.author || !session.padId) return;
const {session: {user} = {}} = socket.client.request;
/* eslint-disable prefer-template -- it doesn't support breaking across multiple lines */
accessLogger.info('[LEAVE]' +
` pad:${session.padId}` +
` socket:${socket.id}` +
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
` authorID:${session.author}` +
(user && user.username ? ` username:${user.username}` : ''));
/* eslint-enable prefer-template */
socket.broadcast.to(session.padId).json.send({
type: 'COLLABROOM',
data: {
type: 'USER_LEAVE',
userInfo: {
colorId: await authorManager.getAuthorColorId(session.author),
userId: session.author,
},
},
});
await hooks.aCallAll('userLeave', {
...session, // For backwards compatibility.
authorId: session.author,
readOnly: session.readonly,
socket,
});
}; };
/** /**
@ -183,8 +202,8 @@ exports.handleMessage = async (socket, message) => {
try { try {
await rateLimiter.consume(socket.request.ip); // consume 1 point per event from IP await rateLimiter.consume(socket.request.ip); // consume 1 point per event from IP
} catch (e) { } catch (e) {
console.warn(`Rate limited: ${socket.request.ip} to reduce the amount of rate limiting ` + messageLogger.warn(`Rate limited IP ${socket.request.ip}. To reduce the amount of rate ` +
'that happens edit the rateLimit values in settings.json'); 'limiting that happens edit the rateLimit values in settings.json');
stats.meter('rateLimited').mark(); stats.meter('rateLimited').mark();
socket.json.send({disconnect: 'rateLimited'}); socket.json.send({disconnect: 'rateLimited'});
return; return;
@ -207,8 +226,14 @@ exports.handleMessage = async (socket, message) => {
} }
if (message.type === 'CLIENT_READY') { if (message.type === 'CLIENT_READY') {
// client tried to auth for the first time (first msg from the client) // Remember this information since we won't have the cookie in further socket.io messages. This
createSessionInfoAuth(thisSession, message); // information will be used to check if the sessionId of this connection is still valid since it
// could have been deleted by the API.
thisSession.auth = {
sessionID: message.sessionID,
padID: message.padId,
token: message.token,
};
} }
const auth = thisSession.auth; const auth = thisSession.auth;
@ -263,7 +288,7 @@ exports.handleMessage = async (socket, message) => {
// Check what type of message we get and delegate to the other methods // Check what type of message we get and delegate to the other methods
if (message.type === 'CLIENT_READY') { if (message.type === 'CLIENT_READY') {
await handleClientReady(socket, message, authorID); await handleClientReady(socket, message);
} else if (message.type === 'CHANGESET_REQ') { } else if (message.type === 'CHANGESET_REQ') {
await handleChangesetRequest(socket, message); await handleChangesetRequest(socket, message);
} else if (message.type === 'COLLABROOM') { } else if (message.type === 'COLLABROOM') {
@ -271,7 +296,7 @@ exports.handleMessage = async (socket, message) => {
messageLogger.warn('Dropped message, COLLABROOM for readonly pad'); messageLogger.warn('Dropped message, COLLABROOM for readonly pad');
} else if (message.data.type === 'USER_CHANGES') { } else if (message.data.type === 'USER_CHANGES') {
stats.counter('pendingEdits').inc(); stats.counter('pendingEdits').inc();
padChannels.emit(message.padId, {socket, message}); // add to pad queue await padChannels.enqueue(message.padId, {socket, message});
} else if (message.data.type === 'USERINFO_UPDATE') { } else if (message.data.type === 'USERINFO_UPDATE') {
await handleUserInfoUpdate(socket, message); await handleUserInfoUpdate(socket, message);
} else if (message.data.type === 'CHAT_MESSAGE') { } else if (message.data.type === 'CHAT_MESSAGE') {
@ -287,8 +312,6 @@ exports.handleMessage = async (socket, message) => {
} else { } else {
messageLogger.warn(`Dropped message, unknown COLLABROOM Data Type ${message.data.type}`); messageLogger.warn(`Dropped message, unknown COLLABROOM Data Type ${message.data.type}`);
} }
} else if (message.type === 'SWITCH_TO_PAD') {
await handleSwitchToPad(socket, message, authorID);
} else { } else {
messageLogger.warn(`Dropped message, unknown Message Type ${message.type}`); messageLogger.warn(`Dropped message, unknown Message Type ${message.type}`);
} }
@ -349,37 +372,38 @@ exports.handleCustomMessage = (padID, msgString) => {
* @param message the message from the client * @param message the message from the client
*/ */
const handleChatMessage = async (socket, message) => { const handleChatMessage = async (socket, message) => {
const time = Date.now(); const chatMessage = ChatMessage.fromObject(message.data.message);
const text = message.data.text;
const {padId, author: authorId} = sessioninfos[socket.id]; const {padId, author: authorId} = sessioninfos[socket.id];
await exports.sendChatMessageToPadClients(time, authorId, text, padId); // Don't trust the user-supplied values.
chatMessage.time = Date.now();
chatMessage.authorId = authorId;
await exports.sendChatMessageToPadClients(chatMessage, padId);
}; };
/** /**
* Sends a chat message to all clients of this pad * Adds a new chat message to a pad and sends it to connected clients.
* @param time the timestamp of the chat message *
* @param userId the author id of the chat message * @param {(ChatMessage|number)} mt - Either a chat message object (recommended) or the timestamp of
* @param text the text of the chat message * the chat message in ms since epoch (deprecated).
* @param padId the padId to send the chat message to * @param {string} puId - If `mt` is a chat message object, this is the destination pad ID.
* Otherwise, this is the user's author ID (deprecated).
* @param {string} [text] - The text of the chat message. Deprecated; use `mt.text` instead.
* @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.
*/ */
exports.sendChatMessageToPadClients = async (time, userId, text, padId) => { exports.sendChatMessageToPadClients = async (mt, puId, text = null, padId = null) => {
// get the pad const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt);
padId = mt instanceof ChatMessage ? puId : padId;
const pad = await padManager.getPad(padId); const pad = await padManager.getPad(padId);
await hooks.aCallAll('chatNewMessage', {message, pad, padId});
// get the author // pad.appendChatMessage() ignores the displayName property so we don't need to wait for
const userName = await authorManager.getAuthorName(userId); // authorManager.getAuthorName() to resolve before saving the message to the database.
const promise = pad.appendChatMessage(message);
// save the chat message message.displayName = await authorManager.getAuthorName(message.userId);
const promise = pad.appendChatMessage(text, userId, time); socketio.sockets.in(padId).json.send({
const msg = {
type: 'COLLABROOM', type: 'COLLABROOM',
data: {type: 'CHAT_MESSAGE', userId, userName, time, text}, data: {type: 'CHAT_MESSAGE', message},
}; });
// broadcast the chat message to everyone on the pad
socketio.sockets.in(padId).json.send(msg);
await promise; await promise;
}; };
@ -536,22 +560,6 @@ const handleUserChanges = async (socket, message) => {
// 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(); stats.counter('pendingEdits').dec();
// Make sure all required fields are present
if (message.data.baseRev == null) {
messageLogger.warn('Dropped message, USER_CHANGES Message has no baseRev!');
return;
}
if (message.data.apool == null) {
messageLogger.warn('Dropped message, USER_CHANGES Message has no apool!');
return;
}
if (message.data.changeset == null) {
messageLogger.warn('Dropped message, USER_CHANGES Message has no changeset!');
return;
}
// 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.
const thisSession = sessioninfos[socket.id]; const thisSession = sessioninfos[socket.id];
@ -560,75 +568,64 @@ const handleUserChanges = async (socket, message) => {
// and always use the copy. atm a message will be ignored if the session is gone even // and always use the copy. atm a message will be ignored if the session is gone even
// if the session was valid when the message arrived in the first place // if the session was valid when the message arrived in the first place
if (!thisSession) { if (!thisSession) {
messageLogger.warn('Dropped message, disconnect happened in the mean time'); messageLogger.warn('Ignoring USER_CHANGES from disconnected user');
return; return;
} }
// get all Vars we need
const baseRev = message.data.baseRev;
const wireApool = (new AttributePool()).fromJsonable(message.data.apool);
let changeset = message.data.changeset;
// Measure time to process edit // Measure time to process edit
const stopWatch = stats.timer('edits').start(); const stopWatch = stats.timer('edits').start();
// get the pad
const pad = await padManager.getPad(thisSession.padId);
// create the changeset
try { try {
try { const {data: {baseRev, apool, changeset}} = message;
// Verify that the changeset has valid syntax and is in canonical form if (baseRev == null) throw new Error('missing baseRev');
Changeset.checkRep(changeset); if (apool == null) throw new Error('missing apool');
if (changeset == null) throw new Error('missing changeset');
const wireApool = (new AttributePool()).fromJsonable(apool);
const pad = await padManager.getPad(thisSession.padId);
// Verify that the attribute indexes used in the changeset are all // Verify that the changeset has valid syntax and is in canonical form
// defined in the accompanying attribute pool. Changeset.checkRep(changeset);
Changeset.eachAttribNumber(changeset, (n) => {
if (!wireApool.getAttrib(n)) { // Verify that the attribute indexes used in the changeset are all
throw new Error(`Attribute pool is missing attribute ${n} for changeset ${changeset}`); // defined in the accompanying attribute pool.
Changeset.eachAttribNumber(changeset, (n) => {
if (!wireApool.getAttrib(n)) {
throw new Error(`Attribute pool is missing attribute ${n} for changeset ${changeset}`);
}
});
// Validate all added 'author' attribs to be the same value as the current user
const iterator = Changeset.opIterator(Changeset.unpack(changeset).ops);
let op;
while (iterator.hasNext()) {
op = iterator.next();
// + can add text with attribs
// = can change or add attribs
// - can have attribs, but they are discarded and don't show up in the attribs -
// but do show up in the pool
op.attribs.split('*').forEach((attr) => {
if (!attr) return;
attr = wireApool.getAttrib(Changeset.parseNum(attr));
if (!attr) return;
// the empty author is used in the clearAuthorship functionality so this
// should be the only exception
if ('author' === attr[0] && (attr[1] !== thisSession.author && attr[1] !== '')) {
throw new Error(`Author ${thisSession.author} tried to submit changes as author ` +
`${attr[1]} in changeset ${changeset}`);
} }
}); });
// Validate all added 'author' attribs to be the same value as the current user
const iterator = Changeset.opIterator(Changeset.unpack(changeset).ops);
let op;
while (iterator.hasNext()) {
op = iterator.next();
// + can add text with attribs
// = can change or add attribs
// - can have attribs, but they are discarded and don't show up in the attribs -
// but do show up in the pool
op.attribs.split('*').forEach((attr) => {
if (!attr) return;
attr = wireApool.getAttrib(attr);
if (!attr) return;
// the empty author is used in the clearAuthorship functionality so this
// should be the only exception
if ('author' === attr[0] && (attr[1] !== thisSession.author && attr[1] !== '')) {
throw new Error(`Author ${thisSession.author} tried to submit changes as author ` +
`${attr[1]} in changeset ${changeset}`);
}
});
}
// ex. adoptChangesetAttribs
// Afaik, it copies the new attributes from the changeset, to the global Attribute Pool
changeset = Changeset.moveOpsToNewPool(changeset, wireApool, pad.pool);
} catch (e) {
// There is an error in this changeset, so just refuse it
socket.json.send({disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
throw new Error(`Can't apply USER_CHANGES from Socket ${socket.id} because: ${e.message}`);
} }
// ex. adoptChangesetAttribs
// Afaik, it copies the new attributes from the changeset, to the global Attribute Pool
let rebasedChangeset = Changeset.moveOpsToNewPool(changeset, wireApool, pad.pool);
// ex. applyUserChanges // ex. applyUserChanges
const apool = pad.pool;
let r = baseRev; let r = baseRev;
// The client's changeset might not be based on the latest revision, // The client's changeset might not be based on the latest revision,
@ -644,40 +641,23 @@ const handleUserChanges = async (socket, message) => {
// 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".
try { // a changeset can be based on an old revision with the same changes in it
// a changeset can be based on an old revision with the same changes in it // prevent eplite from accepting it TODO: better send the client a NEW_CHANGES
// prevent eplite from accepting it TODO: better send the client a NEW_CHANGES // of that revision
// of that revision if (baseRev + 1 === r && c === changeset) throw new Error('Changeset already accepted');
if (baseRev + 1 === r && c === changeset) {
socket.json.send({disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
throw new Error("Won't apply USER_CHANGES, as it contains an already accepted changeset");
}
changeset = Changeset.follow(c, changeset, false, apool); rebasedChangeset = Changeset.follow(c, rebasedChangeset, false, pad.pool);
} catch (e) {
socket.json.send({disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
throw new Error(`Can't apply USER_CHANGES, because ${e.message}`);
}
} }
const prevText = pad.text(); const prevText = pad.text();
if (Changeset.oldLen(changeset) !== prevText.length) { if (Changeset.oldLen(rebasedChangeset) !== prevText.length) {
socket.json.send({disconnect: 'badChangeset'}); throw new Error(
stats.meter('failedChangesets').mark(); `Can't apply changeset ${rebasedChangeset} with oldLen ` +
throw new Error(`Can't apply USER_CHANGES ${changeset} with oldLen ` + `${Changeset.oldLen(rebasedChangeset)} to document of length ${prevText.length}`);
`${Changeset.oldLen(changeset)} to document of length ${prevText.length}`);
} }
try { await pad.appendRevision(rebasedChangeset, thisSession.author);
await pad.appendRevision(changeset, thisSession.author);
} catch (e) {
socket.json.send({disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
throw e;
}
const correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool); const correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool);
if (correctionChangeset) { if (correctionChangeset) {
@ -692,10 +672,13 @@ const handleUserChanges = async (socket, message) => {
await exports.updatePadClients(pad); await exports.updatePadClients(pad);
} catch (err) { } catch (err) {
console.warn(err.stack || err); socket.json.send({disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
messageLogger.warn(`Failed to apply USER_CHANGES from author ${thisSession.author} ` +
`(socket ${socket.id}) on pad ${thisSession.padId}: ${err.stack || err}`);
} finally {
stopWatch.end();
} }
stopWatch.end();
}; };
exports.updatePadClients = async (pad) => { exports.updatePadClients = async (pad) => {
@ -810,58 +793,6 @@ const _correctMarkersInPad = (atext, apool) => {
return builder.toString(); return builder.toString();
}; };
const handleSwitchToPad = async (socket, message, _authorID) => {
const currentSessionInfo = sessioninfos[socket.id];
const padId = currentSessionInfo.padId;
// Check permissions for the new pad.
const newPadIds = await readOnlyManager.getIds(message.padId);
const {session: {user} = {}} = socket.client.request;
const {accessStatus, authorID} = await securityManager.checkAccess(
newPadIds.padId, message.sessionID, message.token, user);
if (accessStatus !== 'grant') {
// Access denied. Send the reason to the user.
socket.json.send({accessStatus});
return;
}
// The same token and session ID were passed to checkAccess in handleMessage, so this second call
// to checkAccess should return the same author ID.
assert(authorID === _authorID);
assert(authorID === currentSessionInfo.author);
// Check if the connection dropped during the access check.
if (sessioninfos[socket.id] !== currentSessionInfo) return;
// clear the session and leave the room
_getRoomSockets(padId).forEach((socket) => {
const sinfo = sessioninfos[socket.id];
if (sinfo && sinfo.author === currentSessionInfo.author) {
// fix user's counter, works on page refresh or if user closes browser window and then rejoins
sessioninfos[socket.id] = {};
socket.leave(padId);
}
});
// start up the new pad
const newSessionInfo = sessioninfos[socket.id];
createSessionInfoAuth(newSessionInfo, message);
await handleClientReady(socket, message, authorID);
};
// Creates/replaces the auth object in the given session info.
const createSessionInfoAuth = (sessionInfo, message) => {
// Remember this information since we won't
// have the cookie in further socket.io messages.
// This information will be used to check if
// the sessionId of this connection is still valid
// since it could have been deleted by the API.
sessionInfo.auth = {
sessionID: message.sessionID,
padID: message.padId,
token: message.token,
};
};
/** /**
* Handles a CLIENT_READY. A CLIENT_READY is the first message from the client * Handles a CLIENT_READY. A CLIENT_READY is the first message from the client
* to the server. The Client sends his token * to the server. The Client sends his token
@ -869,42 +800,33 @@ const createSessionInfoAuth = (sessionInfo, message) => {
* @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, message, authorID) => { const handleClientReady = async (socket, message) => {
// check if all ok const sessionInfo = sessioninfos[socket.id];
if (!message.token) { // Check if the user has already disconnected.
messageLogger.warn('Dropped message, CLIENT_READY Message has no token!'); if (sessionInfo == null) return;
return; assert(sessionInfo.author);
const padIds = await readOnlyManager.getIds(sessionInfo.auth.padID);
sessionInfo.padId = padIds.padId;
sessionInfo.readOnlyPadId = padIds.readOnlyPadId;
sessionInfo.readonly =
padIds.readonly || !webaccess.userCanModify(sessionInfo.auth.padID, socket.client.request);
await hooks.aCallAll('clientReady', message); // Deprecated due to awkward context.
let {colorId: authorColorId, name: authorName} = message.userInfo || {};
if (authorColorId && !/^#(?:[0-9A-F]{3}){1,2}$/i.test(authorColorId)) {
messageLogger.warn(`Ignoring invalid colorId in CLIENT_READY message: ${authorColorId}`);
authorColorId = null;
} }
await Promise.all([
if (!message.padId) { authorName && authorManager.setAuthorName(sessionInfo.author, authorName),
messageLogger.warn('Dropped message, CLIENT_READY Message has no padId!'); authorColorId && authorManager.setAuthorColorId(sessionInfo.author, authorColorId),
return; ]);
} ({colorId: authorColorId, name: authorName} = await authorManager.getAuthor(sessionInfo.author));
if (!message.protocolVersion) {
messageLogger.warn('Dropped message, CLIENT_READY Message has no protocolVersion!');
return;
}
if (message.protocolVersion !== 2) {
messageLogger.warn('Dropped message, CLIENT_READY Message has a unknown protocolVersion ' +
`'${message.protocolVersion}'!`);
return;
}
hooks.callAll('clientReady', message);
// Get ro/rw id:s
const padIds = await readOnlyManager.getIds(message.padId);
// get all authordata of this new user
assert(authorID);
const value = await authorManager.getAuthor(authorID);
const authorColorId = value.colorId;
const authorName = value.name;
// load the pad-object from the database // load the pad-object from the database
const pad = await padManager.getPad(padIds.padId); const pad = await padManager.getPad(sessionInfo.padId);
// 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();
@ -914,7 +836,8 @@ const handleClientReady = async (socket, message, authorID) => {
// get all author data out of the database (in parallel) // get all author data out of the database (in parallel)
const historicalAuthorData = {}; const historicalAuthorData = {};
await Promise.all(authors.map((authorId) => authorManager.getAuthor(authorId).then((author) => { await Promise.all(authors.map(async (authorId) => {
const author = await authorManager.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');
@ -922,45 +845,42 @@ const handleClientReady = async (socket, message, authorID) => {
// Filter author attribs (e.g. don't send author's pads to all clients) // Filter author attribs (e.g. don't send author's pads to all clients)
historicalAuthorData[authorId] = {name: author.name, colorId: author.colorId}; historicalAuthorData[authorId] = {name: author.name, colorId: author.colorId};
} }
}))); }));
// glue the clientVars together, send them and tell the other clients that a new one is there // glue the clientVars together, send them and tell the other clients that a new one is there
// Check that the client is still here. It might have disconnected between callbacks. // Check if the user has disconnected during any of the above awaits.
const sessionInfo = sessioninfos[socket.id]; if (sessionInfo !== sessioninfos[socket.id]) return;
if (sessionInfo == null) return;
// Check if this author is already on the pad, if yes, kick the other sessions! // Check if this author is already on the pad, if yes, kick the other sessions!
const roomSockets = _getRoomSockets(pad.id); const roomSockets = _getRoomSockets(pad.id);
for (const socket of roomSockets) { for (const otherSocket of roomSockets) {
const sinfo = sessioninfos[socket.id]; // The user shouldn't have joined the room yet, but check anyway just in case.
if (sinfo && sinfo.author === authorID) { if (otherSocket.id === socket.id) continue;
const sinfo = sessioninfos[otherSocket.id];
if (sinfo && sinfo.author === sessionInfo.author) {
// fix user's counter, works on page refresh or if user closes browser window and then rejoins // fix user's counter, works on page refresh or if user closes browser window and then rejoins
sessioninfos[socket.id] = {}; sessioninfos[otherSocket.id] = {};
socket.leave(padIds.padId); otherSocket.leave(sessionInfo.padId);
socket.json.send({disconnect: 'userdup'}); otherSocket.json.send({disconnect: 'userdup'});
} }
} }
// Save in sessioninfos that this session belonges to this pad
sessionInfo.padId = padIds.padId;
sessionInfo.readOnlyPadId = padIds.readOnlyPadId;
sessionInfo.readonly =
padIds.readonly || !webaccess.userCanModify(message.padId, socket.client.request);
const {session: {user} = {}} = socket.client.request; const {session: {user} = {}} = socket.client.request;
accessLogger.info(`${`[${pad.head > 0 ? 'ENTER' : 'CREATE'}]` + /* eslint-disable prefer-template -- it doesn't support breaking across multiple lines */
` pad:${padIds.padId}` + accessLogger.info(`[${pad.head > 0 ? 'ENTER' : 'CREATE'}]` +
` pad:${sessionInfo.padId}` +
` socket:${socket.id}` + ` socket:${socket.id}` +
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` + ` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
` authorID:${authorID}`}${ ` authorID:${sessionInfo.author}` +
(user && user.username) ? ` username:${user.username}` : ''}`); (user && user.username ? ` username:${user.username}` : ''));
/* eslint-enable prefer-template */
if (message.reconnect) { if (message.reconnect) {
// If this is a reconnect, we don't have to send the client the ClientVars again // If this is a reconnect, we don't have to send the client the ClientVars again
// Join the pad and start receiving updates // Join the pad and start receiving updates
socket.join(padIds.padId); socket.join(sessionInfo.padId);
// Save the revision in sessioninfos, we take the revision from the info the client send to us // Save the revision in sessioninfos, we take the revision from the info the client send to us
sessionInfo.rev = message.client_rev; sessionInfo.rev = message.client_rev;
@ -989,15 +909,14 @@ const handleClientReady = async (socket, message, authorID) => {
changesets[r] = {}; changesets[r] = {};
} }
// get changesets, author and timestamp needed for pending revisions (in parallel) await Promise.all(revisionsNeeded.map(async (revNum) => {
const promises = [];
for (const revNum of revisionsNeeded) {
const cs = changesets[revNum]; const cs = changesets[revNum];
promises.push(pad.getRevisionChangeset(revNum).then((result) => cs.changeset = result)); [cs.changeset, cs.author, cs.timestamp] = await Promise.all([
promises.push(pad.getRevisionAuthor(revNum).then((result) => cs.author = result)); pad.getRevisionChangeset(revNum),
promises.push(pad.getRevisionDate(revNum).then((result) => cs.timestamp = result)); pad.getRevisionAuthor(revNum),
} pad.getRevisionDate(revNum),
await Promise.all(promises); ]);
}));
// return pending changesets // return pending changesets
for (const r of revisionsNeeded) { for (const r of revisionsNeeded) {
@ -1031,15 +950,14 @@ const handleClientReady = async (socket, message, authorID) => {
apool = attribsForWire.pool.toJsonable(); apool = attribsForWire.pool.toJsonable();
atext.attribs = attribsForWire.translated; atext.attribs = attribsForWire.translated;
} catch (e) { } catch (e) {
console.error(e.stack || e); messageLogger.error(e.stack || e);
socket.json.send({disconnect: 'corruptPad'}); // pull the brakes socket.json.send({disconnect: 'corruptPad'}); // pull the brakes
return; return;
} }
// Warning: never ever send padIds.padId to the client. If the // Warning: never ever send sessionInfo.padId to the client. If the client is read only you
// client is read only you would open a security hole 1 swedish // would open a security hole 1 swedish mile wide...
// mile wide...
const clientVars = { const clientVars = {
skinName: settings.skinName, skinName: settings.skinName,
skinVariants: settings.skinVariants, skinVariants: settings.skinVariants,
@ -1054,7 +972,7 @@ const handleClientReady = async (socket, message, authorID) => {
collab_client_vars: { collab_client_vars: {
initialAttributedText: atext, initialAttributedText: atext,
clientIp: '127.0.0.1', clientIp: '127.0.0.1',
padId: message.padId, padId: sessionInfo.auth.padID,
historicalAuthorData, historicalAuthorData,
apool, apool,
rev: pad.getHeadRevisionNumber(), rev: pad.getHeadRevisionNumber(),
@ -1063,19 +981,19 @@ const handleClientReady = async (socket, message, authorID) => {
colorPalette: authorManager.getColorPalette(), colorPalette: authorManager.getColorPalette(),
clientIp: '127.0.0.1', clientIp: '127.0.0.1',
userColor: authorColorId, userColor: authorColorId,
padId: message.padId, padId: sessionInfo.auth.padID,
padOptions: settings.padOptions, padOptions: settings.padOptions,
padShortcutEnabled: settings.padShortcutEnabled, padShortcutEnabled: settings.padShortcutEnabled,
initialTitle: `Pad: ${message.padId}`, initialTitle: `Pad: ${sessionInfo.auth.padID}`,
opts: {}, opts: {},
// tell the client the number of the latest chat-message, which will be // tell the client the number of the latest chat-message, which will be
// used to request the latest 100 chat-messages later (GET_CHAT_MESSAGES) // used to request the latest 100 chat-messages later (GET_CHAT_MESSAGES)
chatHead: pad.chatHead, chatHead: pad.chatHead,
numConnectedUsers: roomSockets.length, numConnectedUsers: roomSockets.length,
readOnlyId: padIds.readOnlyPadId, readOnlyId: sessionInfo.readOnlyPadId,
readonly: sessionInfo.readonly, readonly: sessionInfo.readonly,
serverTimestamp: Date.now(), serverTimestamp: Date.now(),
userId: authorID, userId: sessionInfo.author,
abiwordAvailable: settings.abiwordAvailable(), abiwordAvailable: settings.abiwordAvailable(),
sofficeAvailable: settings.sofficeAvailable(), sofficeAvailable: settings.sofficeAvailable(),
exportAvailable: settings.exportAvailable(), exportAvailable: settings.exportAvailable(),
@ -1114,7 +1032,7 @@ const handleClientReady = async (socket, message, authorID) => {
} }
// Join the pad and start receiving updates // Join the pad and start receiving updates
socket.join(padIds.padId); socket.join(sessionInfo.padId);
// Send the clientVars to the Client // Send the clientVars to the Client
socket.json.send({type: 'CLIENT_VARS', data: clientVars}); socket.json.send({type: 'CLIENT_VARS', data: clientVars});
@ -1124,14 +1042,14 @@ const handleClientReady = async (socket, message, authorID) => {
} }
// Notify other users about this new user. // Notify other users about this new user.
socket.broadcast.to(padIds.padId).json.send({ socket.broadcast.to(sessionInfo.padId).json.send({
type: 'COLLABROOM', type: 'COLLABROOM',
data: { data: {
type: 'USER_NEWINFO', type: 'USER_NEWINFO',
userInfo: { userInfo: {
colorId: authorColorId, colorId: authorColorId,
name: authorName, name: authorName,
userId: authorID, userId: sessionInfo.author,
}, },
}, },
}); });
@ -1176,6 +1094,15 @@ const handleClientReady = async (socket, message, authorID) => {
socket.json.send(msg); socket.json.send(msg);
})); }));
await hooks.aCallAll('userJoin', {
authorId: sessionInfo.author,
displayName: authorName,
padId: sessionInfo.padId,
readOnly: sessionInfo.readonly,
readOnlyPadId: sessionInfo.readOnlyPadId,
socket,
});
}; };
/** /**
@ -1226,8 +1153,8 @@ const handleChangesetRequest = async (socket, message) => {
data.requestID = message.data.requestID; data.requestID = message.data.requestID;
socket.json.send({type: 'CHANGESET_REQ', data}); socket.json.send({type: 'CHANGESET_REQ', data});
} catch (err) { } catch (err) {
console.error(`Error while handling a changeset request for ${padIds.padId}`, messageLogger.error(`Error while handling a changeset request ${message.data} ` +
err.toString(), message.data); `for ${padIds.padId}: ${err.stack || err}`);
} }
}; };
@ -1237,12 +1164,10 @@ const handleChangesetRequest = async (socket, message) => {
*/ */
const getChangesetInfo = async (padId, startNum, endNum, granularity) => { const getChangesetInfo = async (padId, startNum, endNum, granularity) => {
const pad = await padManager.getPad(padId); const pad = await padManager.getPad(padId);
const head_revision = pad.getHeadRevisionNumber(); const headRevision = pad.getHeadRevisionNumber();
// calculate the last full endnum // calculate the last full endnum
if (endNum > head_revision + 1) { if (endNum > headRevision + 1) endNum = headRevision + 1;
endNum = head_revision + 1;
}
endNum = Math.floor(endNum / granularity) * granularity; endNum = Math.floor(endNum / granularity) * granularity;
const compositesChangesetNeeded = []; const compositesChangesetNeeded = [];
@ -1262,39 +1187,22 @@ const getChangesetInfo = async (padId, startNum, endNum, granularity) => {
revTimesNeeded.push(end - 1); revTimesNeeded.push(end - 1);
} }
// get all needed db values parallel - no await here since // Get all needed db values in parallel.
// it would make all the lookups run in series
// get all needed composite Changesets
const composedChangesets = {}; const composedChangesets = {};
const p1 = Promise.all(
compositesChangesetNeeded.map(
(item) => composePadChangesets(
padId, item.start, item.end
).then(
(changeset) => {
composedChangesets[`${item.start}/${item.end}`] = changeset;
}
)
)
);
// get all needed revision Dates
const revisionDate = []; const revisionDate = [];
const p2 = Promise.all(revTimesNeeded.map((revNum) => pad.getRevisionDate(revNum) const [lines] = await Promise.all([
.then((revDate) => { getPadLines(padId, startNum - 1),
revisionDate[revNum] = Math.floor(revDate / 1000); // Get all needed composite Changesets.
}) ...compositesChangesetNeeded.map(async (item) => {
)); const changeset = await composePadChangesets(padId, item.start, item.end);
composedChangesets[`${item.start}/${item.end}`] = changeset;
// get the lines }),
let lines; // Get all needed revision Dates.
const p3 = getPadLines(padId, startNum - 1).then((_lines) => { ...revTimesNeeded.map(async (revNum) => {
lines = _lines; const revDate = await pad.getRevisionDate(revNum);
}); revisionDate[revNum] = Math.floor(revDate / 1000);
}),
// wait for all of the above to complete ]);
await Promise.all([p1, p2, p3]);
// doesn't know what happens here exactly :/ // doesn't know what happens here exactly :/
const timeDeltas = []; const timeDeltas = [];
@ -1304,9 +1212,7 @@ const getChangesetInfo = async (padId, startNum, endNum, granularity) => {
for (let compositeStart = startNum; compositeStart < endNum; compositeStart += granularity) { for (let compositeStart = startNum; compositeStart < endNum; compositeStart += granularity) {
const compositeEnd = compositeStart + granularity; const compositeEnd = compositeStart + granularity;
if (compositeEnd > endNum || compositeEnd > head_revision + 1) { if (compositeEnd > endNum || compositeEnd > headRevision + 1) break;
break;
}
const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`]; const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`];
const backwards = Changeset.inverse(forwards, lines.textlines, lines.alines, pad.apool()); const backwards = Changeset.inverse(forwards, lines.textlines, lines.alines, pad.apool());
@ -1391,7 +1297,8 @@ const composePadChangesets = async (padId, startNum, endNum) => {
return changeset; return changeset;
} catch (e) { } catch (e) {
// r-1 indicates the rev that was build starting with startNum, applying startNum+1, +2, +3 // r-1 indicates the rev that was build starting with startNum, applying startNum+1, +2, +3
console.warn('failed to compose cs in pad:', padId, ' startrev:', startNum, ' current rev:', r); messageLogger.warn(
`failed to compose cs in pad: ${padId} startrev: ${startNum} current rev: ${r}`);
throw e; throw e;
} }
}; };

View file

@ -21,9 +21,11 @@
*/ */
const log4js = require('log4js'); const log4js = require('log4js');
const messageLogger = log4js.getLogger('message'); const settings = require('../utils/Settings');
const stats = require('../stats'); const stats = require('../stats');
const logger = log4js.getLogger('socket.io');
/** /**
* Saves all components * Saves all components
* key is the component name * key is the component name
@ -31,53 +33,57 @@ const stats = require('../stats');
*/ */
const components = {}; const components = {};
let socket; let io;
/** /**
* adds a component * adds a component
*/ */
exports.addComponent = (moduleName, module) => { exports.addComponent = (moduleName, module) => {
// save the component if (module == null) return exports.deleteComponent(moduleName);
components[moduleName] = module; components[moduleName] = module;
module.setSocketIO(io);
// give the module the socket
module.setSocketIO(socket);
}; };
exports.deleteComponent = (moduleName) => { delete components[moduleName]; };
/** /**
* sets the socket.io and adds event functions for routing * sets the socket.io and adds event functions for routing
*/ */
exports.setSocketIO = (_socket) => { exports.setSocketIO = (_io) => {
// save this socket internaly io = _io;
socket = _socket;
io.sockets.on('connection', (socket) => {
const ip = settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip;
logger.debug(`${socket.id} connected from IP ${ip}`);
socket.sockets.on('connection', (client) => {
// wrap the original send function to log the messages // wrap the original send function to log the messages
client._send = client.send; socket._send = socket.send;
client.send = (message) => { socket.send = (message) => {
messageLogger.debug(`to ${client.id}: ${JSON.stringify(message)}`); logger.debug(`to ${socket.id}: ${JSON.stringify(message)}`);
client._send(message); socket._send(message);
}; };
// tell all components about this connect // tell all components about this connect
for (const i of Object.keys(components)) { for (const i of Object.keys(components)) {
components[i].handleConnect(client); components[i].handleConnect(socket);
} }
client.on('message', async (message) => { socket.on('message', (message, ack = () => {}) => {
if (message.protocolVersion && message.protocolVersion !== 2) {
messageLogger.warn(`Protocolversion header is not correct: ${JSON.stringify(message)}`);
return;
}
if (!message.component || !components[message.component]) { if (!message.component || !components[message.component]) {
messageLogger.error(`Can't route the message: ${JSON.stringify(message)}`); logger.error(`Can't route the message: ${JSON.stringify(message)}`);
return; return;
} }
messageLogger.debug(`from ${client.id}: ${JSON.stringify(message)}`); logger.debug(`from ${socket.id}: ${JSON.stringify(message)}`);
await components[message.component].handleMessage(client, message); (async () => await components[message.component].handleMessage(socket, message))().then(
(val) => ack(null, val),
(err) => {
logger.error(`Error while handling message from ${socket.id}: ${err.stack || err}`);
ack({name: err.name, message: err.message});
});
}); });
client.on('disconnect', () => { socket.on('disconnect', (reason) => {
logger.debug(`${socket.id} disconnected: ${reason}`);
// store the lastDisconnect as a timestamp, this is useful if you want to know // store the lastDisconnect as a timestamp, this is useful if you want to know
// when the last user disconnected. If your activePads is 0 and totalUsers is 0 // when the last user disconnected. If your activePads is 0 and totalUsers is 0
// you can say, if there has been no active pads or active users for 10 minutes // you can say, if there has been no active pads or active users for 10 minutes
@ -85,7 +91,7 @@ exports.setSocketIO = (_socket) => {
stats.gauge('lastDisconnect', () => Date.now()); stats.gauge('lastDisconnect', () => Date.now());
// tell all components about this disconnect // tell all components about this disconnect
for (const i of Object.keys(components)) { for (const i of Object.keys(components)) {
components[i].handleDisconnect(client); components[i].handleDisconnect(socket);
} }
}); });
}); });

View file

@ -1,48 +1,44 @@
'use strict'; 'use strict';
const eejs = require('../../eejs'); const eejs = require('../../eejs');
const fs = require('fs'); const fsp = require('fs').promises;
const hooks = require('../../../static/js/pluginfw/hooks'); const hooks = require('../../../static/js/pluginfw/hooks');
const plugins = require('../../../static/js/pluginfw/plugins'); const plugins = require('../../../static/js/pluginfw/plugins');
const settings = require('../../utils/Settings'); const settings = require('../../utils/Settings');
exports.expressCreateServer = (hookName, args, cb) => { exports.expressCreateServer = (hookName, {app}) => {
args.app.get('/admin/settings', (req, res) => { app.get('/admin/settings', (req, res) => {
res.send(eejs.require('ep_etherpad-lite/templates/admin/settings.html', { res.send(eejs.require('ep_etherpad-lite/templates/admin/settings.html', {
req, req,
settings: '', settings: '',
errors: [], errors: [],
})); }));
}); });
return cb();
}; };
exports.socketio = (hookName, args, cb) => { exports.socketio = (hookName, {io}) => {
const io = args.io.of('/settings'); io.of('/settings').on('connection', (socket) => {
io.on('connection', (socket) => {
const {session: {user: {is_admin: isAdmin} = {}} = {}} = socket.conn.request; const {session: {user: {is_admin: isAdmin} = {}} = {}} = socket.conn.request;
if (!isAdmin) return; if (!isAdmin) return;
socket.on('load', (query) => { socket.on('load', async (query) => {
fs.readFile('settings.json', 'utf8', (err, data) => { let data;
if (err) { try {
return console.log(err); data = await fsp.readFile(settings.settingsFilename, 'utf8');
} } catch (err) {
return console.log(err);
// if showSettingsInAdminPage is set to false, then return NOT_ALLOWED in the result }
if (settings.showSettingsInAdminPage === false) { // if showSettingsInAdminPage is set to false, then return NOT_ALLOWED in the result
socket.emit('settings', {results: 'NOT_ALLOWED'}); if (settings.showSettingsInAdminPage === false) {
} else { socket.emit('settings', {results: 'NOT_ALLOWED'});
socket.emit('settings', {results: data}); } else {
} socket.emit('settings', {results: data});
}); }
}); });
socket.on('saveSettings', (settings) => { socket.on('saveSettings', async (newSettings) => {
fs.writeFile('settings.json', settings, (err) => { await fsp.writeFile(settings.settingsFilename, newSettings);
if (err) throw err; socket.emit('saveprogress', 'saved');
socket.emit('saveprogress', 'saved');
});
}); });
socket.on('restartServer', async () => { socket.on('restartServer', async () => {
@ -53,5 +49,4 @@ exports.socketio = (hookName, args, cb) => {
await hooks.aCallAll('restartServer'); await hooks.aCallAll('restartServer');
}); });
}); });
return cb();
}; };

View file

@ -4,6 +4,7 @@ const log4js = require('log4js');
const clientLogger = log4js.getLogger('client'); const clientLogger = log4js.getLogger('client');
const formidable = require('formidable'); const formidable = require('formidable');
const apiHandler = require('../../handler/APIHandler'); const apiHandler = require('../../handler/APIHandler');
const util = require('util');
exports.expressCreateServer = (hookName, args, cb) => { exports.expressCreateServer = (hookName, args, cb) => {
// The Etherpad client side sends information about how a disconnect happened // The Etherpad client side sends information about how a disconnect happened
@ -14,18 +15,26 @@ exports.expressCreateServer = (hookName, args, cb) => {
}); });
}); });
const parseJserrorForm = async (req) => await new Promise((resolve, reject) => {
const form = new formidable.IncomingForm();
form.maxFileSize = 1; // Files are not expected. Not sure if 0 means unlimited, so 1 is used.
form.on('error', (err) => reject(err));
form.parse(req, (err, fields) => err != null ? reject(err) : resolve(fields.errorInfo));
});
// The Etherpad client side sends information about client side javscript errors // The Etherpad client side sends information about client side javscript errors
args.app.post('/jserror', (req, res) => { args.app.post('/jserror', (req, res, next) => {
new formidable.IncomingForm().parse(req, (err, fields, files) => { (async () => {
let data; const data = JSON.parse(await parseJserrorForm(req));
try { clientLogger.warn(`${data.msg} --`, {
data = JSON.parse(fields.errorInfo); [util.inspect.custom]: (depth, options) => {
} catch (e) { // Depth is forced to infinity to ensure that all of the provided data is logged.
return res.end(); options = Object.assign({}, options, {depth: Infinity, colors: true});
} return util.inspect(data, options);
clientLogger.warn(`${data.msg} --`, data); },
});
res.end('OK'); res.end('OK');
}); })().catch((err) => next(err || new Error(err)));
}); });
// Provide a possibility to query the latest available API version // Provide a possibility to query the latest available API version

View file

@ -1,27 +0,0 @@
'use strict';
const readOnlyManager = require('../../db/ReadOnlyManager');
const hasPadAccess = require('../../padaccess');
const exporthtml = require('../../utils/ExportHtml');
exports.expressCreateServer = (hookName, args, cb) => {
// serve read only pad
args.app.get('/ro/:id', async (req, res) => {
// translate the read only pad to a padId
const padId = await readOnlyManager.getPadId(req.params.id);
if (padId == null) {
res.status(404).send('404 - Not Found');
return;
}
// we need that to tell hasPadAcess about the pad
req.params.pad = padId;
if (await hasPadAccess(req, res)) {
// render the html document
const html = await exporthtml.getPadHTMLDocument(padId, null);
res.send(html);
}
});
return cb();
};

View file

@ -19,23 +19,18 @@
const db = require('../db/DB'); const db = require('../db/DB');
const hooks = require('../../static/js/pluginfw/hooks'); const hooks = require('../../static/js/pluginfw/hooks');
exports.getPadRaw = async (padId) => { exports.getPadRaw = async (padId, readOnlyId) => {
const padKey = `pad:${padId}`; const keyPrefixRead = `pad:${padId}`;
const padcontent = await db.get(padKey); const keyPrefixWrite = readOnlyId ? `pad:${readOnlyId}` : keyPrefixRead;
const padcontent = await db.get(keyPrefixRead);
const records = [padKey]; const keySuffixes = [''];
for (let i = 0; i <= padcontent.head; i++) { for (let i = 0; i <= padcontent.head; i++) keySuffixes.push(`:revs:${i}`);
records.push(`${padKey}:revs:${i}`); for (let i = 0; i <= padcontent.chatHead; i++) keySuffixes.push(`:chat:${i}`);
}
for (let i = 0; i <= padcontent.chatHead; i++) {
records.push(`${padKey}:chat:${i}`);
}
const data = {}; const data = {};
for (const key of records) { for (const keySuffix of keySuffixes) {
// For each piece of info about a pad. const entry = data[keyPrefixWrite + keySuffix] = await db.get(keyPrefixRead + keySuffix);
const entry = data[key] = await db.get(key);
// Get the Pad Authors // Get the Pad Authors
if (entry.pool && entry.pool.numToAttrib) { if (entry.pool && entry.pool.numToAttrib) {
@ -50,7 +45,7 @@ exports.getPadRaw = async (padId) => {
if (authorEntry) { if (authorEntry) {
data[`globalAuthor:${authorId}`] = authorEntry; data[`globalAuthor:${authorId}`] = authorEntry;
if (authorEntry.padIDs) { if (authorEntry.padIDs) {
authorEntry.padIDs = padId; authorEntry.padIDs = readOnlyId || padId;
} }
} }
} }
@ -63,7 +58,8 @@ exports.getPadRaw = async (padId) => {
const prefixes = await hooks.aCallAll('exportEtherpadAdditionalContent'); const prefixes = await hooks.aCallAll('exportEtherpadAdditionalContent');
await Promise.all(prefixes.map(async (prefix) => { await Promise.all(prefixes.map(async (prefix) => {
const key = `${prefix}:${padId}`; const key = `${prefix}:${padId}`;
data[key] = await db.get(key); const writeKey = readOnlyId ? `${prefix}:${readOnlyId}` : key;
data[writeKey] = await db.get(key);
})); }));
return data; return data;

View file

@ -53,7 +53,8 @@ exports._analyzeLine = (text, aline, apool) => {
if (aline) { if (aline) {
const opIter = Changeset.opIterator(aline); const opIter = Changeset.opIterator(aline);
if (opIter.hasNext()) { if (opIter.hasNext()) {
let listType = Changeset.opAttributeValue(opIter.next(), 'list', apool); const op = opIter.next();
let listType = Changeset.opAttributeValue(op, 'list', apool);
if (listType) { if (listType) {
lineMarker = 1; lineMarker = 1;
listType = /([a-z]+)([0-9]+)/.exec(listType); listType = /([a-z]+)([0-9]+)/.exec(listType);
@ -62,10 +63,7 @@ exports._analyzeLine = (text, aline, apool) => {
line.listLevel = Number(listType[2]); line.listLevel = Number(listType[2]);
} }
} }
} const start = Changeset.opAttributeValue(op, 'start', apool);
const opIter2 = Changeset.opIterator(aline);
if (opIter2.hasNext()) {
const start = Changeset.opAttributeValue(opIter2.next(), 'start', apool);
if (start) { if (start) {
line.start = start; line.start = start;
} }

View file

@ -457,7 +457,7 @@ const getHTMLFromAtext = async (pad, atext, authorColors) => {
return pieces.join(''); return pieces.join('');
}; };
exports.getPadHTMLDocument = async (padId, revNum) => { exports.getPadHTMLDocument = async (padId, revNum, readOnlyId) => {
const pad = await padManager.getPad(padId); const pad = await padManager.getPad(padId);
// Include some Styles into the Head for Export // Include some Styles into the Head for Export
@ -475,7 +475,7 @@ exports.getPadHTMLDocument = async (padId, revNum) => {
return eejs.require('ep_etherpad-lite/templates/export_html.html', { return eejs.require('ep_etherpad-lite/templates/export_html.html', {
body: html, body: html,
padId: Security.escapeHTML(padId), padId: Security.escapeHTML(readOnlyId || padId),
extraCSS: stylesForExportCSS, extraCSS: stylesForExportCSS,
}); });
}; };

View file

@ -18,26 +18,21 @@
const log4js = require('log4js'); const log4js = require('log4js');
const Changeset = require('../../static/js/Changeset'); const Changeset = require('../../static/js/Changeset');
const contentcollector = require('../../static/js/contentcollector'); const contentcollector = require('../../static/js/contentcollector');
const cheerio = require('cheerio'); const jsdom = require('jsdom');
const rehype = require('rehype'); const rehype = require('rehype');
const minifyWhitespace = require('rehype-minify-whitespace'); const minifyWhitespace = require('rehype-minify-whitespace');
const apiLogger = log4js.getLogger('ImportHtml');
const processor = rehype().use(minifyWhitespace, {newlines: false});
exports.setPadHTML = async (pad, html) => { exports.setPadHTML = async (pad, html) => {
const apiLogger = log4js.getLogger('ImportHtml'); html = String(await processor.process(html));
const {window: {document}} = new jsdom.JSDOM(html);
rehype()
.use(minifyWhitespace, {newlines: false})
.process(html, (err, output) => {
html = String(output);
});
const $ = cheerio.load(html);
// Appends a line break, used by Etherpad to ensure a caret is available // Appends a line break, used by Etherpad to ensure a caret is available
// below the last line of an import // below the last line of an import
$('body').append('<p></p>'); document.body.appendChild(document.createElement('p'));
const doc = $('body')[0];
apiLogger.debug('html:'); apiLogger.debug('html:');
apiLogger.debug(html); apiLogger.debug(html);
@ -46,12 +41,10 @@ exports.setPadHTML = async (pad, html) => {
const cc = contentcollector.makeContentCollector(true, null, pad.pool); const cc = contentcollector.makeContentCollector(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(doc); cc.collectContent(document.body);
} catch (e) { } catch (err) {
apiLogger.warn('HTML was not properly formed', e); apiLogger.warn(`Error processing HTML: ${err.stack || err}`);
throw err;
// don't process the HTML because it was bad
throw e;
} }
const result = cc.finish(); const result = cc.finish();
@ -70,35 +63,29 @@ exports.setPadHTML = async (pad, html) => {
apiLogger.debug(newText); apiLogger.debug(newText);
const newAttribs = `${result.lineAttribs.join('|1+1')}|1+1`; const newAttribs = `${result.lineAttribs.join('|1+1')}|1+1`;
const eachAttribRun = (attribs, func /* (startInNewText, endInNewText, attribs)*/) => {
const attribsIter = Changeset.opIterator(attribs);
let textIndex = 0;
const newTextStart = 0;
const newTextEnd = newText.length;
while (attribsIter.hasNext()) {
const op = attribsIter.next();
const nextIndex = textIndex + op.chars;
if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {
func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs);
}
textIndex = nextIndex;
}
};
// 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 = Changeset.builder(1);
// assemble each line into the builder // assemble each line into the builder
eachAttribRun(newAttribs, (start, end, attribs) => { const attribsIter = Changeset.opIterator(newAttribs);
builder.insert(newText.substring(start, end), attribs); let textIndex = 0;
}); const newTextStart = 0;
const newTextEnd = newText.length;
while (attribsIter.hasNext()) {
const op = attribsIter.next();
const nextIndex = textIndex + op.chars;
if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {
const start = Math.max(newTextStart, textIndex);
const end = Math.min(newTextEnd, nextIndex);
builder.insert(newText.substring(start, end), op.attribs);
}
textIndex = nextIndex;
}
// the changeset is ready! // the changeset is ready!
const theChangeset = builder.toString(); const theChangeset = builder.toString();
apiLogger.debug(`The changeset: ${theChangeset}`); apiLogger.debug(`The changeset: ${theChangeset}`);
await Promise.all([ await pad.setText('\n');
pad.setText('\n'), if (!Changeset.isIdentity(theChangeset)) await pad.appendRevision(theChangeset);
pad.appendRevision(theChangeset),
]);
}; };

View file

@ -22,17 +22,15 @@ const fs = require('fs').promises;
const log4js = require('log4js'); const log4js = require('log4js');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const runCmd = require('./run_cmd');
const settings = require('./Settings'); const settings = require('./Settings');
const spawn = require('child_process').spawn;
const libreOfficeLogger = log4js.getLogger('LibreOffice'); const logger = log4js.getLogger('LibreOffice');
const doConvertTask = async (task) => { const doConvertTask = async (task) => {
const tmpDir = os.tmpdir(); const tmpDir = os.tmpdir();
const p = runCmd([
libreOfficeLogger.debug( settings.soffice,
`Converting ${task.srcFile} to format ${task.type}. The result will be put in ${tmpDir}`);
const soffice = spawn(settings.soffice, [
'--headless', '--headless',
'--invisible', '--invisible',
'--nologo', '--nologo',
@ -43,33 +41,32 @@ const doConvertTask = async (task) => {
task.srcFile, task.srcFile,
'--outdir', '--outdir',
tmpDir, tmpDir,
]); ], {stdio: [
null,
(line) => logger.info(`[${p.child.pid}] stdout: ${line}`),
(line) => logger.error(`[${p.child.pid}] stderr: ${line}`),
]});
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.
const hangTimeout = setTimeout(() => { const hangTimeout = setTimeout(() => {
soffice.stdin.pause(); // required to kill hanging threads logger.error(`[${p.child.pid}] Conversion timed out; killing LibreOffice...`);
soffice.kill(); p.child.kill();
}, 120000); }, 120000);
let stdoutBuffer = ''; try {
soffice.stdout.on('data', (data) => { stdoutBuffer += data.toString(); }); await p;
soffice.stderr.on('data', (data) => { stdoutBuffer += data.toString(); }); } catch (err) {
await new Promise((resolve, reject) => { logger.error(`[${p.child.pid}] Conversion failed: ${err.stack || err}`);
soffice.on('exit', (code) => { throw err;
clearTimeout(hangTimeout); } finally {
if (code !== 0) { clearTimeout(hangTimeout);
const err = }
new Error(`LibreOffice died with exit code ${code} and message: ${stdoutBuffer}`); logger.info(`[${p.child.pid}] Conversion done.`);
libreOfficeLogger.error(err.stack);
return reject(err);
}
resolve();
});
});
const filename = path.basename(task.srcFile); const filename = path.basename(task.srcFile);
const sourceFile = `${filename.substr(0, filename.lastIndexOf('.'))}.${task.fileExtension}`; const sourceFile = `${filename.substr(0, filename.lastIndexOf('.'))}.${task.fileExtension}`;
const sourcePath = path.join(tmpDir, sourceFile); const sourcePath = path.join(tmpDir, sourceFile);
libreOfficeLogger.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);
}; };

View file

@ -5,56 +5,27 @@
const CleanCSS = require('clean-css'); const CleanCSS = require('clean-css');
const Terser = require('terser'); const Terser = require('terser');
const fsp = require('fs').promises;
const path = require('path'); const path = require('path');
const Threads = require('threads'); const Threads = require('threads');
const compressJS = (content) => Terser.minify(content); const compressJS = (content) => Terser.minify(content);
const compressCSS = (filename, ROOT_DIR) => new Promise((res, rej) => { const compressCSS = async (filename, ROOT_DIR) => {
const absPath = path.resolve(ROOT_DIR, filename);
try { try {
const absPath = path.resolve(ROOT_DIR, filename);
/*
* Changes done to migrate CleanCSS 3.x -> 4.x:
*
* 1. Rework the rebase logic, because the API was simplified (but we have
* less control now). See:
* https://github.com/jakubpawlowicz/clean-css/blob/08f3a74925524d30bbe7ac450979de0a8a9e54b2/README.md#important-40-breaking-changes
*
* EXAMPLE:
* The URLs contained in a CSS file (including all the stylesheets
* imported by it) residing on disk at:
* /home/muxator/etherpad/src/static/css/pad.css
*
* Will be rewritten rebasing them to:
* /home/muxator/etherpad/src/static/css
*
* 2. CleanCSS.minify() can either receive a string containing the CSS, or
* an array of strings. In that case each array element is interpreted as
* an absolute local path from which the CSS file is read.
*
* In version 4.x, CleanCSS API was simplified, eliminating the
* relativeTo parameter, and thus we cannot use our already loaded
* "content" argument, but we have to wrap the absolute path to the CSS
* in an array and ask the library to read it by itself.
*/
const basePath = path.dirname(absPath); const basePath = path.dirname(absPath);
const output = await new CleanCSS({
new CleanCSS({
rebase: true, rebase: true,
rebaseTo: basePath, rebaseTo: basePath,
}).minify([absPath], (errors, minified) => { }).minify([absPath]);
if (errors) return rej(errors); return output.styles;
return res(minified.styles);
});
} catch (error) { } catch (error) {
// on error, just yield the un-minified original, but write a log message // on error, just yield the un-minified original, but write a log message
console.error(`Unexpected error minifying ${filename} (${absPath}): ${error}`); console.error(`Unexpected error minifying ${filename} (${absPath}): ${error}`);
callback(null, content); return await fsp.readFile(absPath, 'utf8');
} }
}); };
Threads.expose({ Threads.expose({
compressJS, compressJS,

View file

@ -28,6 +28,7 @@
*/ */
const absolutePaths = require('./AbsolutePaths'); const absolutePaths = require('./AbsolutePaths');
const deepEqual = require('fast-deep-equal/es6');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
@ -39,10 +40,39 @@ const suppressDisableMsg = ' -- To suppress these warning messages change ' +
'suppressErrorsInPadText to true in your settings.json\n'; 'suppressErrorsInPadText to true in your settings.json\n';
const _ = require('underscore'); const _ = require('underscore');
const logger = log4js.getLogger('settings');
// Exported values that settings.json and credentials.json cannot override.
const nonSettings = [
'credentialsFilename',
'settingsFilename',
];
// This is a function to make it easy to create a new instance. It is important to not reuse a
// config object after passing it to log4js.configure() because that method mutates the object. :(
const defaultLogConfig = () => ({appenders: [{type: 'console'}]});
const defaultLogLevel = 'INFO';
const initLogging = (logLevel, config) => {
// log4js.configure() modifies exports.logconfig so check for equality first.
const logConfigIsDefault = deepEqual(config, defaultLogConfig());
log4js.configure(config);
log4js.setGlobalLogLevel(logLevel);
log4js.replaceConsole();
// Log the warning after configuring log4js to increase the chances the user will see it.
if (!logConfigIsDefault) logger.warn('The logconfig setting is deprecated.');
};
// Initialize logging as early as possible with reasonable defaults. Logging will be re-initialized
// with the user's chosen log level and logger config after the settings have been loaded.
initLogging(defaultLogLevel, defaultLogConfig());
/* Root path of the installation */ /* Root path of the installation */
exports.root = absolutePaths.findEtherpadRoot(); exports.root = absolutePaths.findEtherpadRoot();
console.log('All relative paths will be interpreted relative to the identified ' + logger.info('All relative paths will be interpreted relative to the identified ' +
`Etherpad base dir: ${exports.root}`); `Etherpad base dir: ${exports.root}`);
exports.settingsFilename = absolutePaths.makeAbsolute(argv.settings || 'settings.json');
exports.credentialsFilename = absolutePaths.makeAbsolute(argv.credentials || 'credentials.json');
/** /**
* The app title, visible e.g. in the browser window * The app title, visible e.g. in the browser window
@ -234,7 +264,7 @@ exports.allowUnknownFileEnds = true;
/** /**
* The log level of log4js * The log level of log4js
*/ */
exports.loglevel = 'INFO'; exports.loglevel = defaultLogLevel;
/** /**
* Disable IP logging * Disable IP logging
@ -264,7 +294,7 @@ exports.indentationOnNewLine = true;
/* /*
* log4js appender configuration * log4js appender configuration
*/ */
exports.logconfig = {appenders: [{type: 'console'}]}; exports.logconfig = defaultLogConfig();
/* /*
* Session Key, do not sure this. * Session Key, do not sure this.
@ -450,7 +480,7 @@ exports.getGitCommit = () => {
} }
version = version.substring(0, 7); version = version.substring(0, 7);
} catch (e) { } catch (e) {
console.warn(`Can't get git version for server header\n${e.message}`); logger.warn(`Can't get git version for server header\n${e.message}`);
} }
return version; return version;
}; };
@ -467,9 +497,14 @@ exports.getEpVersion = () => require('../../package.json').version;
*/ */
const storeSettings = (settingsObj) => { const storeSettings = (settingsObj) => {
for (const i of Object.keys(settingsObj || {})) { for (const i of Object.keys(settingsObj || {})) {
if (nonSettings.includes(i)) {
logger.warn(`Ignoring setting: '${i}'`);
continue;
}
// test if the setting starts with a lowercase character // test if the setting starts with a lowercase character
if (i.charAt(0).search('[a-z]') !== 0) { if (i.charAt(0).search('[a-z]') !== 0) {
console.warn(`Settings should start with a lowercase character: '${i}'`); logger.warn(`Settings should start with a lowercase character: '${i}'`);
} }
// we know this setting, so we overwrite it // we know this setting, so we overwrite it
@ -482,7 +517,7 @@ const storeSettings = (settingsObj) => {
} }
} else { } else {
// this setting is unknown, output a warning and throw it away // this setting is unknown, output a warning and throw it away
console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`); logger.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`);
} }
} }
}; };
@ -598,10 +633,10 @@ const lookupEnvironmentVariables = (obj) => {
const defaultValue = match[3]; const defaultValue = match[3];
if ((envVarValue === undefined) && (defaultValue === undefined)) { if ((envVarValue === undefined) && (defaultValue === undefined)) {
console.warn(`Environment variable "${envVarName}" does not contain any value for ` + logger.warn(`Environment variable "${envVarName}" does not contain any value for ` +
`configuration key "${key}", and no default was given. Using null. ` + `configuration key "${key}", and no default was given. Using null. ` +
'THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF ETHERPAD; you should ' + 'THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF ETHERPAD; you should ' +
'explicitly use "null" as the default if you want to continue to use null.'); 'explicitly use "null" as the default if you want to continue to use null.');
/* /*
* We have to return null, because if we just returned undefined, the * We have to return null, because if we just returned undefined, the
@ -611,8 +646,8 @@ const lookupEnvironmentVariables = (obj) => {
} }
if ((envVarValue === undefined) && (defaultValue !== undefined)) { if ((envVarValue === undefined) && (defaultValue !== undefined)) {
console.debug(`Environment variable "${envVarName}" not found for ` + logger.debug(`Environment variable "${envVarName}" not found for ` +
`configuration key "${key}". Falling back to default value.`); `configuration key "${key}". Falling back to default value.`);
return coerceValue(defaultValue); return coerceValue(defaultValue);
} }
@ -623,7 +658,7 @@ const lookupEnvironmentVariables = (obj) => {
* For numeric and boolean strings let's convert it to proper types before * For numeric and boolean strings let's convert it to proper types before
* returning it, in order to maintain backward compatibility. * returning it, in order to maintain backward compatibility.
*/ */
console.debug( logger.debug(
`Configuration key "${key}" will be read from environment variable "${envVarName}"`); `Configuration key "${key}" will be read from environment variable "${envVarName}"`);
return coerceValue(envVarValue); return coerceValue(envVarValue);
@ -650,11 +685,11 @@ const parseSettings = (settingsFilename, isSettings) => {
if (isSettings) { if (isSettings) {
settingsType = 'settings'; settingsType = 'settings';
notFoundMessage = 'Continuing using defaults!'; notFoundMessage = 'Continuing using defaults!';
notFoundFunction = console.warn; notFoundFunction = logger.warn.bind(logger);
} else { } else {
settingsType = 'credentials'; settingsType = 'credentials';
notFoundMessage = 'Ignoring.'; notFoundMessage = 'Ignoring.';
notFoundFunction = console.info; notFoundFunction = logger.info.bind(logger);
} }
try { try {
@ -672,42 +707,30 @@ const parseSettings = (settingsFilename, isSettings) => {
const settings = JSON.parse(settingsStr); const settings = JSON.parse(settingsStr);
console.info(`${settingsType} loaded from: ${settingsFilename}`); logger.info(`${settingsType} loaded from: ${settingsFilename}`);
const replacedSettings = lookupEnvironmentVariables(settings); const replacedSettings = lookupEnvironmentVariables(settings);
return replacedSettings; return replacedSettings;
} catch (e) { } catch (e) {
console.error(`There was an error processing your ${settingsType} ` + logger.error(`There was an error processing your ${settingsType} ` +
`file from ${settingsFilename}: ${e.message}`); `file from ${settingsFilename}: ${e.message}`);
process.exit(1); process.exit(1);
} }
}; };
exports.reloadSettings = () => { exports.reloadSettings = () => {
// Discover where the settings file lives const settings = parseSettings(exports.settingsFilename, true);
const settingsFilename = absolutePaths.makeAbsolute(argv.settings || 'settings.json'); const credentials = parseSettings(exports.credentialsFilename, false);
// Discover if a credential file exists
const credentialsFilename = absolutePaths.makeAbsolute(argv.credentials || 'credentials.json');
// try to parse the settings
const settings = parseSettings(settingsFilename, true);
// try to parse the credentials
const credentials = parseSettings(credentialsFilename, false);
storeSettings(settings); storeSettings(settings);
storeSettings(credentials); storeSettings(credentials);
log4js.configure(exports.logconfig);// Configure the logging appenders initLogging(exports.loglevel, exports.logconfig);
log4js.setGlobalLogLevel(exports.loglevel);// set loglevel
log4js.replaceConsole();
if (!exports.skinName) { if (!exports.skinName) {
console.warn('No "skinName" parameter found. Please check out settings.json.template and ' + logger.warn('No "skinName" parameter found. Please check out settings.json.template and ' +
'update your settings.json. Falling back to the default "colibris".'); 'update your settings.json. Falling back to the default "colibris".');
exports.skinName = 'colibris'; exports.skinName = 'colibris';
} }
@ -717,8 +740,8 @@ exports.reloadSettings = () => {
const countPieces = exports.skinName.split(path.sep).length; const countPieces = exports.skinName.split(path.sep).length;
if (countPieces !== 1) { if (countPieces !== 1) {
console.error(`skinName must be the name of a directory under "${skinBasePath}". This is ` + logger.error(`skinName must be the name of a directory under "${skinBasePath}". This is ` +
`not valid: "${exports.skinName}". Falling back to the default "colibris".`); `not valid: "${exports.skinName}". Falling back to the default "colibris".`);
exports.skinName = 'colibris'; exports.skinName = 'colibris';
} }
@ -728,21 +751,20 @@ exports.reloadSettings = () => {
// what if someone sets skinName == ".." or "."? We catch him! // what if someone sets skinName == ".." or "."? We catch him!
if (absolutePaths.isSubdir(skinBasePath, skinPath) === false) { if (absolutePaths.isSubdir(skinBasePath, skinPath) === false) {
console.error(`Skin path ${skinPath} must be a subdirectory of ${skinBasePath}. ` + logger.error(`Skin path ${skinPath} must be a subdirectory of ${skinBasePath}. ` +
'Falling back to the default "colibris".'); 'Falling back to the default "colibris".');
exports.skinName = 'colibris'; exports.skinName = 'colibris';
skinPath = path.join(skinBasePath, exports.skinName); skinPath = path.join(skinBasePath, exports.skinName);
} }
if (fs.existsSync(skinPath) === false) { if (fs.existsSync(skinPath) === false) {
console.error( logger.error(`Skin path ${skinPath} does not exist. Falling back to the default "colibris".`);
`Skin path ${skinPath} does not exist. Falling back to the default "colibris".`);
exports.skinName = 'colibris'; exports.skinName = 'colibris';
skinPath = path.join(skinBasePath, exports.skinName); skinPath = path.join(skinBasePath, exports.skinName);
} }
console.info(`Using skin "${exports.skinName}" in dir: ${skinPath}`); logger.info(`Using skin "${exports.skinName}" in dir: ${skinPath}`);
} }
if (exports.abiword) { if (exports.abiword) {
@ -754,7 +776,7 @@ exports.reloadSettings = () => {
if (!exports.suppressErrorsInPadText) { if (!exports.suppressErrorsInPadText) {
exports.defaultPadText += `\nError: ${abiwordError}${suppressDisableMsg}`; exports.defaultPadText += `\nError: ${abiwordError}${suppressDisableMsg}`;
} }
console.error(`${abiwordError} File location: ${exports.abiword}`); logger.error(`${abiwordError} File location: ${exports.abiword}`);
exports.abiword = null; exports.abiword = null;
} }
}); });
@ -770,7 +792,7 @@ exports.reloadSettings = () => {
if (!exports.suppressErrorsInPadText) { if (!exports.suppressErrorsInPadText) {
exports.defaultPadText += `\nError: ${sofficeError}${suppressDisableMsg}`; exports.defaultPadText += `\nError: ${sofficeError}${suppressDisableMsg}`;
} }
console.error(`${sofficeError} File location: ${exports.soffice}`); logger.error(`${sofficeError} File location: ${exports.soffice}`);
exports.soffice = null; exports.soffice = null;
} }
}); });
@ -780,18 +802,18 @@ exports.reloadSettings = () => {
const sessionkeyFilename = absolutePaths.makeAbsolute(argv.sessionkey || './SESSIONKEY.txt'); const sessionkeyFilename = absolutePaths.makeAbsolute(argv.sessionkey || './SESSIONKEY.txt');
try { try {
exports.sessionKey = fs.readFileSync(sessionkeyFilename, 'utf8'); exports.sessionKey = fs.readFileSync(sessionkeyFilename, 'utf8');
console.info(`Session key loaded from: ${sessionkeyFilename}`); logger.info(`Session key loaded from: ${sessionkeyFilename}`);
} catch (e) { } catch (e) {
console.info( logger.info(
`Session key file "${sessionkeyFilename}" not found. Creating with random contents.`); `Session key file "${sessionkeyFilename}" not found. Creating with random contents.`);
exports.sessionKey = randomString(32); exports.sessionKey = randomString(32);
fs.writeFileSync(sessionkeyFilename, exports.sessionKey, 'utf8'); fs.writeFileSync(sessionkeyFilename, exports.sessionKey, 'utf8');
} }
} else { } else {
console.warn('Declaring the sessionKey in the settings.json is deprecated. ' + logger.warn('Declaring the sessionKey in the settings.json is deprecated. ' +
'This value is auto-generated now. Please remove the setting from the file. -- ' + 'This value is auto-generated now. Please remove the setting from the file. -- ' +
'If you are seeing this error after restarting using the Admin User ' + 'If you are seeing this error after restarting using the Admin User ' +
'Interface then you can ignore this message.'); 'Interface then you can ignore this message.');
} }
if (exports.dbType === 'dirty') { if (exports.dbType === 'dirty') {
@ -801,13 +823,13 @@ exports.reloadSettings = () => {
} }
exports.dbSettings.filename = absolutePaths.makeAbsolute(exports.dbSettings.filename); exports.dbSettings.filename = absolutePaths.makeAbsolute(exports.dbSettings.filename);
console.warn(`${dirtyWarning} File location: ${exports.dbSettings.filename}`); logger.warn(`${dirtyWarning} File location: ${exports.dbSettings.filename}`);
} }
if (exports.ip === '') { if (exports.ip === '') {
// using Unix socket for connectivity // using Unix socket for connectivity
console.warn('The settings file contains an empty string ("") for the "ip" parameter. The ' + logger.warn('The settings file contains an empty string ("") for the "ip" parameter. The ' +
'"port" parameter will be interpreted as the path to a Unix socket to bind at.'); '"port" parameter will be interpreted as the path to a Unix socket to bind at.');
} }
/* /*
@ -822,7 +844,7 @@ exports.reloadSettings = () => {
* TODO: remove the "?v=randomstring" parameter, and replace with hashed filenames instead * TODO: remove the "?v=randomstring" parameter, and replace with hashed filenames instead
*/ */
exports.randomVersionString = randomString(4); exports.randomVersionString = randomString(4);
console.log(`Random string used for versioning assets: ${exports.randomVersionString}`); logger.info(`Random string used for versioning assets: ${exports.randomVersionString}`);
}; };
exports.exportedForTestingOnly = { exports.exportedForTestingOnly = {

View file

@ -9,11 +9,7 @@ function PadDiff(pad, fromRev, toRev) {
} }
const range = pad.getValidRevisionRange(fromRev, toRev); const range = pad.getValidRevisionRange(fromRev, toRev);
if (!range) { if (!range) throw new Error(`Invalid revision range. startRev: ${fromRev} endRev: ${toRev}`);
throw new Error(`${'Invalid revision range.' +
' startRev: '}${fromRev
} endRev: ${toRev}`);
}
this._pad = pad; this._pad = pad;
this._fromRev = range.startRev; this._fromRev = range.startRev;
@ -164,7 +160,7 @@ PadDiff.prototype._createDiffAtext = async function () {
if (superChangeset == null) { if (superChangeset == null) {
superChangeset = changeset; superChangeset = changeset;
} else { } else {
superChangeset = Changeset.composeWithDeletions(superChangeset, changeset, this._pad.pool); superChangeset = Changeset.compose(superChangeset, changeset, this._pad.pool);
} }
} }
@ -277,7 +273,7 @@ PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) {
let curChar = 0; let curChar = 0;
let curLineOpIter = null; let curLineOpIter = null;
let curLineOpIterLine; let curLineOpIterLine;
const curLineNextOp = Changeset.newOp('+'); let curLineNextOp = Changeset.newOp('+');
const unpacked = Changeset.unpack(cs); const unpacked = Changeset.unpack(cs);
const csIter = Changeset.opIterator(unpacked.ops); const csIter = Changeset.opIterator(unpacked.ops);
@ -289,15 +285,13 @@ PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) {
curLineOpIter = Changeset.opIterator(aLinesGet(curLine)); curLineOpIter = Changeset.opIterator(aLinesGet(curLine));
curLineOpIterLine = curLine; curLineOpIterLine = curLine;
let indexIntoLine = 0; let indexIntoLine = 0;
let done = false; while (curLineOpIter.hasNext()) {
while (!done) { curLineNextOp = curLineOpIter.next();
curLineOpIter.next(curLineNextOp);
if (indexIntoLine + curLineNextOp.chars >= curChar) { if (indexIntoLine + curLineNextOp.chars >= curChar) {
curLineNextOp.chars -= (curChar - indexIntoLine); curLineNextOp.chars -= (curChar - indexIntoLine);
done = true; break;
} else {
indexIntoLine += curLineNextOp.chars;
} }
indexIntoLine += curLineNextOp.chars;
} }
} }
@ -311,7 +305,7 @@ PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) {
} }
if (!curLineNextOp.chars) { if (!curLineNextOp.chars) {
curLineOpIter.next(curLineNextOp); curLineNextOp = curLineOpIter.hasNext() ? curLineOpIter.next() : Changeset.newOp();
} }
const charsToUse = Math.min(numChars, curLineNextOp.chars); const charsToUse = Math.min(numChars, curLineNextOp.chars);

View file

@ -2,7 +2,7 @@
"pad.js": [ "pad.js": [
"pad.js" "pad.js"
, "pad_utils.js" , "pad_utils.js"
, "$js-cookie/src/js.cookie.js" , "$js-cookie/dist/js.cookie.js"
, "security.js" , "security.js"
, "$security.js" , "$security.js"
, "vendors/browser.js" , "vendors/browser.js"
@ -19,9 +19,10 @@
, "pad_impexp.js" , "pad_impexp.js"
, "pad_savedrevs.js" , "pad_savedrevs.js"
, "pad_connectionstatus.js" , "pad_connectionstatus.js"
, "ChatMessage.js"
, "chat.js" , "chat.js"
, "vendors/gritter.js" , "vendors/gritter.js"
, "$js-cookie/src/js.cookie.js" , "$js-cookie/dist/js.cookie.js"
, "$tinycon/tinycon.js" , "$tinycon/tinycon.js"
, "vendors/farbtastic.js" , "vendors/farbtastic.js"
, "skin_variants.js" , "skin_variants.js"
@ -33,7 +34,7 @@
, "colorutils.js" , "colorutils.js"
, "draggable.js" , "draggable.js"
, "pad_utils.js" , "pad_utils.js"
, "$js-cookie/src/js.cookie.js" , "$js-cookie/dist/js.cookie.js"
, "vendors/browser.js" , "vendors/browser.js"
, "pad_cookie.js" , "pad_cookie.js"
, "pad_editor.js" , "pad_editor.js"
@ -73,7 +74,7 @@
, "scroll.js" , "scroll.js"
, "caretPosition.js" , "caretPosition.js"
, "pad_utils.js" , "pad_utils.js"
, "$js-cookie/src/js.cookie.js" , "$js-cookie/dist/js.cookie.js"
, "security.js" , "security.js"
, "$security.js" , "$security.js"
] ]

3359
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -30,55 +30,53 @@
} }
], ],
"dependencies": { "dependencies": {
"async": "^3.2.0", "async": "^3.2.1",
"async-stacktrace": "0.0.2", "clean-css": "^5.2.1",
"channels": "0.0.4",
"cheerio": "0.22.0",
"clean-css": "4.2.3",
"cookie-parser": "1.4.5", "cookie-parser": "1.4.5",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"ejs": "^3.1.6", "ejs": "^3.1.6",
"etherpad-require-kernel": "1.0.11", "etherpad-require-kernel": "^1.0.15",
"etherpad-yajsml": "0.0.4", "etherpad-yajsml": "0.0.12",
"express": "4.17.1", "express": "4.17.1",
"express-rate-limit": "5.2.6", "express-rate-limit": "5.5.0",
"express-session": "1.17.2", "express-session": "1.17.2",
"fast-deep-equal": "^3.1.3",
"find-root": "1.1.0", "find-root": "1.1.0",
"formidable": "1.2.2", "formidable": "1.2.2",
"http-errors": "1.8.0", "http-errors": "1.8.0",
"js-cookie": "^2.2.1", "js-cookie": "^3.0.1",
"jsdom": "^17.0.0",
"jsonminify": "0.4.1", "jsonminify": "0.4.1",
"languages4translatewiki": "0.1.3", "languages4translatewiki": "0.1.3",
"lodash.clonedeep": "4.5.0", "lodash.clonedeep": "4.5.0",
"log4js": "0.6.38", "log4js": "0.6.38",
"measured-core": "1.51.1", "measured-core": "^2.0.0",
"mime-types": "^2.1.27", "mime-types": "^2.1.33",
"nodeify": "1.0.1", "npm": "^6.14.15",
"npm": "6.14.13", "openapi-backend": "^4.2.0",
"openapi-backend": "^3.9.1", "proxy-addr": "^2.0.7",
"proxy-addr": "^2.0.6", "rate-limiter-flexible": "^2.3.1",
"rate-limiter-flexible": "^2.1.4", "rehype": "^11.0.0",
"rehype": "^10.0.0",
"rehype-minify-whitespace": "^4.0.5", "rehype-minify-whitespace": "^4.0.5",
"request": "2.88.2", "request": "2.88.2",
"resolve": "1.20.0", "resolve": "1.20.0",
"security": "1.0.0", "security": "1.0.0",
"semver": "5.7.1", "semver": "^7.3.5",
"socket.io": "^2.4.1", "socket.io": "^2.4.1",
"terser": "^4.7.0", "terser": "^5.9.0",
"threads": "^1.4.0", "threads": "^1.7.0",
"tiny-worker": "^2.3.0", "tiny-worker": "^2.3.0",
"tinycon": "0.6.8", "tinycon": "0.6.8",
"ueberdb2": "^1.4.7", "ueberdb2": "^1.4.18",
"underscore": "1.13.1", "underscore": "1.13.1",
"unorm": "1.6.0", "unorm": "1.6.0",
"wtfnode": "^0.9.0" "wtfnode": "^0.9.1"
}, },
"bin": { "bin": {
"etherpad-lite": "node/server.js" "etherpad-lite": "node/server.js"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^7.28.0", "eslint": "^7.32.0",
"eslint-config-etherpad": "^2.0.0", "eslint-config-etherpad": "^2.0.0",
"eslint-plugin-cypress": "^2.11.3", "eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-eslint-comments": "^3.2.0",
@ -87,16 +85,17 @@
"eslint-plugin-prefer-arrow": "^1.2.3", "eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-promise": "^5.1.0", "eslint-plugin-promise": "^5.1.0",
"eslint-plugin-you-dont-need-lodash-underscore": "^6.12.0", "eslint-plugin-you-dont-need-lodash-underscore": "^6.12.0",
"etherpad-cli-client": "0.0.9", "etherpad-cli-client": "^0.1.12",
"mocha": "7.1.2", "mocha": "^9.1.1",
"mocha-froth": "^0.2.10", "mocha-froth": "^0.2.10",
"nodeify": "^1.0.1",
"openapi-schema-validation": "^0.4.2", "openapi-schema-validation": "^0.4.2",
"selenium-webdriver": "^4.0.0-beta.3", "selenium-webdriver": "^4.0.0-rc-1",
"set-cookie-parser": "^2.4.6", "set-cookie-parser": "^2.4.8",
"sinon": "^9.2.0", "sinon": "^11.1.2",
"split-grid": "^1.0.11", "split-grid": "^1.0.11",
"superagent": "^3.8.3", "superagent": "^6.1.0",
"supertest": "4.0.2" "supertest": "^6.1.6"
}, },
"eslintConfig": { "eslintConfig": {
"ignorePatterns": [ "ignorePatterns": [
@ -247,6 +246,6 @@
"test": "mocha --timeout 120000 --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs", "test": "mocha --timeout 120000 --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs",
"test-container": "mocha --timeout 5000 tests/container/specs/api" "test-container": "mocha --timeout 5000 tests/container/specs/api"
}, },
"version": "1.8.14", "version": "1.8.15",
"license": "Apache-2.0" "license": "Apache-2.0"
} }

View file

@ -92,12 +92,18 @@
#chattext p { #chattext p {
padding: 3px; padding: 3px;
overflow-x: hidden; overflow-x: hidden;
white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }
#chattext .time { #chattext .time {
float: right; float: right;
font-style: italic; font-style: italic;
font-size: .85rem; /*
* 'smaller' is relative to the parent element, so if the parent has its own
* 'font-size: smaller' rule then the timestamp will become even smaller (as
* desired).
*/
font-size: smaller;
opacity: .8; opacity: .8;
margin-left: 3px; margin-left: 3px;
margin-right: 2px; margin-right: 2px;
@ -109,6 +115,7 @@
} }
#chatinputbox #chatinput { #chatinputbox #chatinput {
width: 100%; width: 100%;
resize: vertical;
} }

View file

@ -55,9 +55,6 @@
} }
.buttonicon-clearauthorship:before { .buttonicon-clearauthorship:before {
content: "\e843"; content: "\e843";
left: -9px;
position: absolute;
top: -9px;
} }
.buttonicon-settings:before { .buttonicon-settings:before {
content: "\e851"; content: "\e851";
@ -87,9 +84,9 @@
.ep_font_color .buttonicon:before { content: '\e84e' !important; border-bottom: solid 2px #e42a2a; } .ep_font_color .buttonicon:before { content: '\e84e' !important; border-bottom: solid 2px #e42a2a; }
.buttonicon-underline:before { .buttonicon-underline:before {
top: -8px; /* The baseline of the underscore glyph seems off. Compensate for it here. */
left: -8px; top: 0.1em;
position: absolute; position: relative;
} }
/* COPY CSS GENERATED BY FONTELLO HERE */ /* COPY CSS GENERATED BY FONTELLO HERE */

View file

@ -45,9 +45,6 @@
.popup input[type=text], #users input[type=text] { .popup input[type=text], #users input[type=text] {
outline: none; outline: none;
} }
.popup a {
text-decoration: none
}
.popup h1 { .popup h1 {
font-size: 1.8rem; font-size: 1.8rem;
margin-bottom: 10px; margin-bottom: 10px;

1
src/static/empty.html Normal file
View file

@ -0,0 +1 @@
<!DOCTYPE html><html><head><title>Empty</title></head><body></body></html>

View file

@ -103,12 +103,12 @@ AttributeManager.prototype = _(AttributeManager.prototype).extend({
const markerWidth = this.lineHasMarker(row) ? 1 : 0; const markerWidth = this.lineHasMarker(row) ? 1 : 0;
if (lineLength - markerWidth < 0) throw new Error(`line ${row} has negative length`); if (lineLength - markerWidth < 0) throw new Error(`line ${row} has negative length`);
const startCol = row === start[0] ? start[1] : markerWidth; if (start[1] < 0) throw new RangeError('selection starts at negative column');
if (startCol - markerWidth < 0) throw new RangeError('selection starts before line start'); const startCol = Math.max(markerWidth, row === start[0] ? start[1] : 0);
if (startCol > lineLength) throw new RangeError('selection starts after line end'); if (startCol > lineLength) throw new RangeError('selection starts after line end');
const endCol = row === end[0] ? end[1] : lineLength; if (end[1] < 0) throw new RangeError('selection ends at negative column');
if (endCol - markerWidth < 0) throw new RangeError('selection ends before line start'); const endCol = Math.max(markerWidth, row === end[0] ? end[1] : lineLength);
if (endCol > lineLength) throw new RangeError('selection ends after line end'); if (endCol > lineLength) throw new RangeError('selection ends after line end');
if (startCol > endCol) throw new RangeError('selection ends before it starts'); if (startCol > endCol) throw new RangeError('selection ends before it starts');

View file

@ -23,75 +23,171 @@
* limitations under the License. * limitations under the License.
*/ */
/* /**
An AttributePool maintains a mapping from [key,value] Pairs called * A `[key, value]` pair of strings describing a text attribute.
Attributes to Numbers (unsigened integers) and vice versa. These numbers are *
used to reference Attributes in Changesets. * @typedef {[string, string]} Attribute
*/ */
const AttributePool = function () { /**
this.numToAttrib = {}; // e.g. {0: ['foo','bar']} * Maps an attribute's identifier to the attribute.
this.attribToNum = {}; // e.g. {'foo,bar': 0} *
this.nextNum = 0; * @typedef {Object.<number, Attribute>} NumToAttrib
}; */
AttributePool.prototype.putAttrib = function (attrib, dontAddIfAbsent) { /**
const str = String(attrib); * An intermediate representation of the contents of an attribute pool, suitable for serialization
if (str in this.attribToNum) { * via `JSON.stringify` and transmission to another user.
return this.attribToNum[str]; *
* @typedef {Object} Jsonable
* @property {NumToAttrib} numToAttrib - The pool's attributes and their identifiers.
* @property {number} nextNum - The attribute ID to assign to the next new attribute.
*/
/**
* Represents an attribute pool, which is a collection of attributes (pairs of key and value
* strings) along with their identifiers (non-negative integers).
*
* The attribute pool enables attribute interning: rather than including the key and value strings
* in changesets, changesets reference attributes by their identifiers.
*
* There is one attribute pool per pad, and it includes every current and historical attribute used
* in the pad.
*/
class AttributePool {
constructor() {
/**
* Maps an attribute identifier to the attribute's `[key, value]` string pair.
*
* TODO: Rename to `_numToAttrib` once all users have been migrated to call `getAttrib` instead
* of accessing this directly.
* @private
* TODO: Convert to an array.
* @type {NumToAttrib}
*/
this.numToAttrib = {}; // e.g. {0: ['foo','bar']}
/**
* Maps the string representation of an attribute (`String([key, value])`) to its non-negative
* identifier.
*
* TODO: Rename to `_attribToNum` once all users have been migrated to use `putAttrib` instead
* of accessing this directly.
* @private
* TODO: Convert to a `Map` object.
* @type {Object.<string, number>}
*/
this.attribToNum = {}; // e.g. {'foo,bar': 0}
/**
* The attribute ID to assign to the next new attribute.
*
* TODO: This property will not be necessary once `numToAttrib` is converted to an array (just
* push onto the array).
*
* @private
* @type {number}
*/
this.nextNum = 0;
} }
if (dontAddIfAbsent) {
return -1; /**
* Add an attribute to the attribute set, or query for an existing attribute identifier.
*
* @param {Attribute} attrib - The attribute's `[key, value]` pair of strings.
* @param {boolean} [dontAddIfAbsent=false] - If true, do not insert the attribute into the pool
* if the attribute does not already exist in the pool. This can be used to test for
* membership in the pool without mutating the pool.
* @returns {number} The attribute's identifier, or -1 if the attribute is not in the pool.
*/
putAttrib(attrib, dontAddIfAbsent = false) {
const str = String(attrib);
if (str in this.attribToNum) {
return this.attribToNum[str];
}
if (dontAddIfAbsent) {
return -1;
}
const num = this.nextNum++;
this.attribToNum[str] = num;
this.numToAttrib[num] = [String(attrib[0] || ''), String(attrib[1] || '')];
return num;
} }
const num = this.nextNum++;
this.attribToNum[str] = num;
this.numToAttrib[num] = [String(attrib[0] || ''), String(attrib[1] || '')];
return num;
};
AttributePool.prototype.getAttrib = function (num) { /**
const pair = this.numToAttrib[num]; * @param {number} num - The identifier of the attribute to fetch.
if (!pair) { * @returns {Attribute} The attribute with the given identifier, or nullish if there is no such
return pair; * attribute.
*/
getAttrib(num) {
const pair = this.numToAttrib[num];
if (!pair) {
return pair;
}
return [pair[0], pair[1]]; // return a mutable copy
} }
return [pair[0], pair[1]]; // return a mutable copy
};
AttributePool.prototype.getAttribKey = function (num) { /**
const pair = this.numToAttrib[num]; * @param {number} num - The identifier of the attribute to fetch.
if (!pair) return ''; * @returns {string} Eqivalent to `getAttrib(num)[0]` if the attribute exists, otherwise the empty
return pair[0]; * string.
}; */
getAttribKey(num) {
AttributePool.prototype.getAttribValue = function (num) { const pair = this.numToAttrib[num];
const pair = this.numToAttrib[num]; if (!pair) return '';
if (!pair) return ''; return pair[0];
return pair[1];
};
AttributePool.prototype.eachAttrib = function (func) {
for (const n of Object.keys(this.numToAttrib)) {
const pair = this.numToAttrib[n];
func(pair[0], pair[1]);
} }
};
AttributePool.prototype.toJsonable = function () { /**
return { * @param {number} num - The identifier of the attribute to fetch.
numToAttrib: this.numToAttrib, * @returns {string} Eqivalent to `getAttrib(num)[1]` if the attribute exists, otherwise the empty
nextNum: this.nextNum, * string.
}; */
}; getAttribValue(num) {
const pair = this.numToAttrib[num];
AttributePool.prototype.fromJsonable = function (obj) { if (!pair) return '';
this.numToAttrib = obj.numToAttrib; return pair[1];
this.nextNum = obj.nextNum;
this.attribToNum = {};
for (const n of Object.keys(this.numToAttrib)) {
this.attribToNum[String(this.numToAttrib[n])] = Number(n);
} }
return this;
};
/**
* Executes a callback for each attribute in the pool.
*
* @param {Function} func - Callback to call with two arguments: key and value. Its return value
* is ignored.
*/
eachAttrib(func) {
for (const n of Object.keys(this.numToAttrib)) {
const pair = this.numToAttrib[n];
func(pair[0], pair[1]);
}
}
/**
* @returns {Jsonable} An object that can be passed to `fromJsonable` to reconstruct this
* attribute pool. The returned object can be converted to JSON.
*/
toJsonable() {
return {
numToAttrib: this.numToAttrib,
nextNum: this.nextNum,
};
}
/**
* Replace the contents of this attribute pool with values from a previous call to `toJsonable`.
*
* @param {Jsonable} obj - Object returned by `toJsonable` containing the attributes and their
* identifiers.
*/
fromJsonable(obj) {
this.numToAttrib = obj.numToAttrib;
this.nextNum = obj.nextNum;
this.attribToNum = {};
for (const n of Object.keys(this.numToAttrib)) {
this.attribToNum[String(this.numToAttrib[n])] = Number(n);
}
return this;
}
}
module.exports = AttributePool; module.exports = AttributePool;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,80 @@
'use strict';
/**
* Represents a chat message stored in the database and transmitted among users. Plugins can extend
* the object with additional properties.
*
* Supports serialization to JSON.
*/
class ChatMessage {
static fromObject(obj) {
return Object.assign(new ChatMessage(), obj);
}
/**
* @param {?string} [text] - Initial value of the `text` property.
* @param {?string} [authorId] - Initial value of the `authorId` property.
* @param {?number} [time] - Initial value of the `time` property.
*/
constructor(text = null, authorId = null, time = null) {
/**
* The raw text of the user's chat message (before any rendering or processing).
*
* @type {?string}
*/
this.text = text;
/**
* The user's author ID.
*
* @type {?string}
*/
this.authorId = authorId;
/**
* The message's timestamp, as milliseconds since epoch.
*
* @type {?number}
*/
this.time = time;
/**
* The user's display name.
*
* @type {?string}
*/
this.displayName = null;
}
/**
* Alias of `authorId`, for compatibility with old plugins.
*
* @deprecated Use `authorId` instead.
* @type {string}
*/
get userId() { return this.authorId; }
set userId(val) { this.authorId = val; }
/**
* Alias of `displayName`, for compatibility with old plugins.
*
* @deprecated Use `displayName` instead.
* @type {string}
*/
get userName() { return this.displayName; }
set userName(val) { this.displayName = val; }
// TODO: Delete this method once users are unlikely to roll back to a version of Etherpad that
// doesn't support authorId and displayName.
toJSON() {
return {
...this,
authorId: undefined,
displayName: undefined,
userId: this.authorId,
userName: this.displayName,
};
}
}
module.exports = ChatMessage;

View file

@ -110,7 +110,6 @@ const Ace2Editor = function () {
'importAText', 'importAText',
'focus', 'focus',
'setEditable', 'setEditable',
'getFormattedCode',
'setOnKeyPress', 'setOnKeyPress',
'setOnKeyDown', 'setOnKeyDown',
'setNotifyDirty', 'setNotifyDirty',
@ -121,7 +120,6 @@ const Ace2Editor = function () {
'applyPreparedChangesetToBase', 'applyPreparedChangesetToBase',
'setUserChangeNotificationCallback', 'setUserChangeNotificationCallback',
'setAuthorInfo', 'setAuthorInfo',
'setAuthorSelectionRange',
'callWithAce', 'callWithAce',
'execCommand', 'execCommand',
'replaceRange', 'replaceRange',
@ -138,8 +136,6 @@ const Ace2Editor = function () {
this.exportText = () => loaded ? info.ace_exportText() : '(awaiting init)\n'; this.exportText = () => loaded ? info.ace_exportText() : '(awaiting init)\n';
this.getDebugProperty = (prop) => info.ace_getDebugProperty(prop);
this.getInInternationalComposition = this.getInInternationalComposition =
() => loaded ? info.ace_getInInternationalComposition() : null; () => loaded ? info.ace_getInInternationalComposition() : null;
@ -153,9 +149,6 @@ const Ace2Editor = function () {
// changes, and modify the changeset to be applied by applyPreparedChangesetToBase accordingly. // changes, and modify the changeset to be applied by applyPreparedChangesetToBase accordingly.
this.prepareUserChangeset = () => loaded ? info.ace_prepareUserChangeset() : null; this.prepareUserChangeset = () => loaded ? info.ace_prepareUserChangeset() : null;
// returns array of {error: <browser Error object>, time: +new Date()}
this.getUnhandledErrors = () => loaded ? info.ace_getUnhandledErrors() : [];
const addStyleTagsFor = (doc, files) => { const addStyleTagsFor = (doc, files) => {
for (const file of files) { for (const file of files) {
const link = doc.createElement('link'); const link = doc.createElement('link');
@ -198,7 +191,9 @@ const Ace2Editor = function () {
// - Chrome never fires any events on the frame or document. Eventually the document's // - Chrome never fires any events on the frame or document. Eventually the document's
// readyState becomes 'complete' even though it never fires a readystatechange event. // readyState becomes 'complete' even though it never fires a readystatechange event.
// - Safari behaves like Chrome. // - Safari behaves like Chrome.
outerFrame.srcdoc = '<!DOCTYPE html>'; // srcdoc is avoided because Firefox's Content Security Policy engine does not properly handle
// 'self' with nested srcdoc iframes: https://bugzilla.mozilla.org/show_bug.cgi?id=1721296
outerFrame.src = '../static/empty.html';
info.frame = outerFrame; info.frame = outerFrame;
document.getElementById(containerId).appendChild(outerFrame); document.getElementById(containerId).appendChild(outerFrame);
const outerWindow = outerFrame.contentWindow; const outerWindow = outerFrame.contentWindow;
@ -228,6 +223,10 @@ const Ace2Editor = function () {
sideDiv.id = 'sidediv'; sideDiv.id = 'sidediv';
sideDiv.classList.add('sidediv'); sideDiv.classList.add('sidediv');
outerDocument.body.appendChild(sideDiv); outerDocument.body.appendChild(sideDiv);
const sideDivInner = outerDocument.createElement('div');
sideDivInner.id = 'sidedivinner';
sideDivInner.classList.add('sidedivinner');
sideDiv.appendChild(sideDivInner);
const lineMetricsDiv = outerDocument.createElement('div'); const lineMetricsDiv = outerDocument.createElement('div');
lineMetricsDiv.id = 'linemetricsdiv'; lineMetricsDiv.id = 'linemetricsdiv';
lineMetricsDiv.appendChild(outerDocument.createTextNode('x')); lineMetricsDiv.appendChild(outerDocument.createTextNode('x'));
@ -241,8 +240,7 @@ const Ace2Editor = function () {
innerFrame.allowTransparency = true; // for IE innerFrame.allowTransparency = true; // for IE
// The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above // The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above
// outerFrame.srcdoc. // outerFrame.srcdoc.
innerFrame.srcdoc = '<!DOCTYPE html>'; innerFrame.src = 'empty.html';
innerFrame.ace_outerWin = outerWindow;
outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild); outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild);
const innerWindow = innerFrame.contentWindow; const innerWindow = innerFrame.contentWindow;
@ -284,7 +282,6 @@ const Ace2Editor = function () {
// <body> tag // <body> tag
innerDocument.body.id = 'innerdocbody'; innerDocument.body.id = 'innerdocbody';
innerDocument.body.classList.add('innerdocbody'); innerDocument.body.classList.add('innerdocbody');
innerDocument.body.setAttribute('role', 'application');
innerDocument.body.setAttribute('spellcheck', 'false'); innerDocument.body.setAttribute('spellcheck', 'false');
innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); // &nbsp; innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); // &nbsp;

View file

@ -22,8 +22,6 @@
* limitations under the License. * limitations under the License.
*/ */
const Security = require('./security');
const isNodeText = (node) => (node.nodeType === 3); const isNodeText = (node) => (node.nodeType === 3);
const getAssoc = (obj, name) => obj[`_magicdom_${name}`]; const getAssoc = (obj, name) => obj[`_magicdom_${name}`];
@ -60,8 +58,6 @@ const binarySearchInfinite = (expectedLength, func) => {
return binarySearch(i, func); return binarySearch(i, func);
}; };
const htmlPrettyEscape = (str) => Security.escapeHTML(str).replace(/\r?\n/g, '\\n');
const noop = () => {}; const noop = () => {};
exports.isNodeText = isNodeText; exports.isNodeText = isNodeText;
@ -69,5 +65,4 @@ exports.getAssoc = getAssoc;
exports.setAssoc = setAssoc; exports.setAssoc = setAssoc;
exports.binarySearch = binarySearch; exports.binarySearch = binarySearch;
exports.binarySearchInfinite = binarySearchInfinite; exports.binarySearchInfinite = binarySearchInfinite;
exports.htmlPrettyEscape = htmlPrettyEscape;
exports.noop = noop; exports.noop = noop;

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,7 @@
'use strict'; 'use strict';
/* global socketio */
$(document).ready(() => { $(document).ready(() => {
const socket = socketio.connect('..', '/settings'); const socket = window.socketio.connect('..', '/settings');
socket.on('connect', () => { socket.on('connect', () => {
socket.emit('load'); socket.emit('load');

View file

@ -0,0 +1,48 @@
// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0
/* Copyright 2021 Richard Hansen <rhansen@rhansen.org> */
'use strict';
// Set up an error handler to display errors that happen during page load. This handler will be
// overridden with a nicer handler by setupGlobalExceptionHandler() in pad_utils.js.
(() => {
const originalHandler = window.onerror;
window.onerror = (...args) => {
const [msg, url, line, col, err] = args;
// Purge the existing HTML and styles for a consistent view.
document.body.textContent = '';
for (const el of document.querySelectorAll('head style, head link[rel="stylesheet"]')) {
el.remove();
}
const box = document.body;
box.textContent = '';
const summary = document.createElement('p');
box.appendChild(summary);
summary.appendChild(document.createTextNode('An error occurred while loading the page:'));
const msgBlock = document.createElement('blockquote');
box.appendChild(msgBlock);
msgBlock.style.fontWeight = 'bold';
msgBlock.appendChild(document.createTextNode(msg));
const loc = document.createElement('p');
box.appendChild(loc);
loc.appendChild(document.createTextNode(`in ${url}`));
loc.appendChild(document.createElement('br'));
loc.appendChild(document.createTextNode(`at line ${line}:${col}`));
const stackSummary = document.createElement('p');
box.appendChild(stackSummary);
stackSummary.appendChild(document.createTextNode('Stack trace:'));
const stackBlock = document.createElement('blockquote');
box.appendChild(stackBlock);
const stack = document.createElement('pre');
stackBlock.appendChild(stack);
stack.appendChild(document.createTextNode(err.stack || err.toString()));
if (typeof originalHandler === 'function') originalHandler(...args);
};
})();
// @license-end

View file

@ -164,10 +164,16 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
return true; // break return true; // break
} }
}); });
// deal with someone is the author of a line and changes one character, // some chars are replaced (no attributes change and no length change)
// so the alines won't change // test if there are keep ops at the start of the cs
if (lineChanged === undefined) { if (lineChanged === undefined) {
lineChanged = Changeset.opIterator(Changeset.unpack(changeset).ops).next().lines; lineChanged = 0;
const opIter = Changeset.opIterator(Changeset.unpack(changeset).ops);
if (opIter.hasNext()) {
const op = opIter.next();
if (op.opcode === '=') lineChanged += op.lines;
}
} }
const goToLineNumber = (lineNumber) => { const goToLineNumber = (lineNumber) => {

View file

@ -136,15 +136,6 @@ const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
// that includes old submittedChangeset // that includes old submittedChangeset
toSubmit = Changeset.compose(submittedChangeset, userChangeset, apool); toSubmit = Changeset.compose(submittedChangeset, userChangeset, apool);
} else { } else {
// add forEach function to Array.prototype for IE8
if (!('forEach' in Array.prototype)) {
Array.prototype.forEach = function (action, that /* opt*/) {
for (let i = 0, n = this.length; i < n; i++) {
if (i in this) action.call(that, this[i], i, this);
}
};
}
// Get my authorID // Get my authorID
const authorId = parent.parent.pad.myUserInfo.userId; const authorId = parent.parent.pad.myUserInfo.userId;

View file

@ -15,12 +15,16 @@
* limitations under the License. * limitations under the License.
*/ */
const ChatMessage = require('./ChatMessage');
const padutils = require('./pad_utils').padutils; const padutils = require('./pad_utils').padutils;
const padcookie = require('./pad_cookie').padcookie; const padcookie = require('./pad_cookie').padcookie;
const Tinycon = require('tinycon/tinycon'); const Tinycon = require('tinycon/tinycon');
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
const padeditor = require('./pad_editor').padeditor; const padeditor = require('./pad_editor').padeditor;
// Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463
const normalize = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
exports.chat = (() => { exports.chat = (() => {
let isStuck = false; let isStuck = false;
let userAndChat = false; let userAndChat = false;
@ -99,25 +103,28 @@ exports.chat = (() => {
} }
} }
}, },
send() { async send() {
const text = $('#chatinput').val(); const text = $('#chatinput').val();
if (text.replace(/\s+/, '').length === 0) return; if (text.replace(/\s+/, '').length === 0) return;
this._pad.collabClient.sendMessage({type: 'CHAT_MESSAGE', text}); const message = new ChatMessage(text);
await hooks.aCallAll('chatSendMessage', Object.freeze({message}));
this._pad.collabClient.sendMessage({type: 'CHAT_MESSAGE', message});
$('#chatinput').val(''); $('#chatinput').val('');
}, },
addMessage(msg, increment, isHistoryAdd) { async addMessage(msg, increment, isHistoryAdd) {
msg = ChatMessage.fromObject(msg);
// correct the time // correct the time
msg.time += this._pad.clientTimeOffset; msg.time += this._pad.clientTimeOffset;
if (!msg.userId) { if (!msg.authorId) {
/* /*
* If, for a bug or a database corruption, the message coming from the * If, for a bug or a database corruption, the message coming from the
* server does not contain the userId field (see for example #3731), * server does not contain the authorId field (see for example #3731),
* let's be defensive and replace it with "unknown". * let's be defensive and replace it with "unknown".
*/ */
msg.userId = 'unknown'; msg.authorId = 'unknown';
console.warn( console.warn(
'The "userId" field of a chat message coming from the server was not present. ' + '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.'); 'Replacing with "unknown". This may be a bug or a database corruption.');
} }
@ -128,9 +135,11 @@ exports.chat = (() => {
// the hook args // the hook args
const ctx = { const ctx = {
authorName: msg.userName != null ? msg.userName : html10n.get('pad.userlist.unnamed'), authorName: msg.displayName != null ? msg.displayName : html10n.get('pad.userlist.unnamed'),
author: msg.userId, author: msg.authorId,
text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'), text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'),
message: msg,
rendered: null,
sticky: false, sticky: false,
timestamp: msg.time, timestamp: msg.time,
timeStr: (() => { timeStr: (() => {
@ -149,10 +158,11 @@ exports.chat = (() => {
// does the user already have the chatbox open? // does the user already have the chatbox open?
const chatOpen = $('#chatbox').hasClass('visible'); const chatOpen = $('#chatbox').hasClass('visible');
// does this message contain this user's name? (is the curretn user mentioned?) // does this message contain this user's name? (is the current user mentioned?)
const myName = $('#myusernameedit').val();
const wasMentioned = const wasMentioned =
ctx.text.toLowerCase().indexOf(myName.toLowerCase()) !== -1 && myName !== 'undefined'; 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 the user was mentioned, make the message sticky
if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) { if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) {
@ -161,54 +171,49 @@ exports.chat = (() => {
ctx.sticky = true; ctx.sticky = true;
} }
// Call chat message hook await hooks.aCallAll('chatNewMessage', ctx);
hooks.aCallAll('chatNewMessage', ctx, () => { const cls = authorClass(ctx.author);
const cls = authorClass(ctx.author); const chatMsg = ctx.rendered != null ? $(ctx.rendered) : $('<p>')
const chatMsg = $('<p>') .attr('data-authorId', ctx.author)
.attr('data-authorId', ctx.author) .addClass(cls)
.addClass(cls) .append($('<b>').text(`${ctx.authorName}:`))
.append($('<b>').text(`${ctx.authorName}:`)) .append($('<span>')
.append($('<span>') .addClass('time')
.addClass('time') .addClass(cls)
.addClass(cls) // Hook functions are trusted to not introduce an XSS vulnerability by adding
// Hook functions are trusted to not introduce an XSS vulnerability by adding // unescaped user input to ctx.timeStr.
// unescaped user input to ctx.timeStr. .html(ctx.timeStr))
.html(ctx.timeStr)) .append(' ')
.append(' ') // ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not // introduce an XSS vulnerability by adding unescaped user input.
// introduce an XSS vulnerability by adding unescaped user input. .append($('<div>').html(ctx.text).contents());
.append($('<div>').html(ctx.text).contents()); if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton');
if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton'); else $('#chattext').append(chatMsg);
else $('#chattext').append(chatMsg); chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e));
// should we increment the counter?? // should we increment the counter??
if (increment && !isHistoryAdd) { if (increment && !isHistoryAdd) {
// Update the counter of unread messages // Update the counter of unread messages
let count = Number($('#chatcounter').text()); let count = Number($('#chatcounter').text());
count++; count++;
$('#chatcounter').text(count); $('#chatcounter').text(count);
if (!chatOpen && ctx.duration > 0) { if (!chatOpen && ctx.duration > 0) {
$.gritter.add({ const text = $('<p>')
text: $('<p>') .append($('<span>').addClass('author-name').text(ctx.authorName))
.append($('<span>').addClass('author-name').text(ctx.authorName)) // ctx.text was HTML-escaped before calling the hook. Hook functions are trusted
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted // to not introduce an XSS vulnerability by adding unescaped user input.
// to not introduce an XSS vulnerability by adding unescaped user input. .append($('<div>').html(ctx.text).contents());
.append($('<div>').html(ctx.text).contents()), text.each((i, e) => html10n.translateElement(html10n.translations, e));
sticky: ctx.sticky, $.gritter.add({
time: 5000, text,
position: 'bottom', sticky: ctx.sticky,
class_name: 'chat-gritter-msg', time: ctx.duration,
}); position: 'bottom',
} class_name: 'chat-gritter-msg',
});
} }
}); }
// Clear the chat mentions when the user clicks on the chat input box
$('#chatinput').click(() => {
chatMentions = 0;
Tinycon.setBubble(0);
});
if (!isHistoryAdd) this.scrollDown(); if (!isHistoryAdd) this.scrollDown();
}, },
init(pad) { init(pad) {
@ -224,6 +229,11 @@ exports.chat = (() => {
return false; return false;
} }
}); });
// Clear the chat mentions when the user clicks on the chat input box
$('#chatinput').click(() => {
chatMentions = 0;
Tinycon.setBubble(0);
});
const self = this; const self = this;
$('body:not(#chatinput)').on('keypress', function (evt) { $('body:not(#chatinput)').on('keypress', function (evt) {
@ -238,7 +248,7 @@ exports.chat = (() => {
$('#chatinput').keypress((evt) => { $('#chatinput').keypress((evt) => {
// if the user typed enter, fire the send // if the user typed enter, fire the send
if (evt.which === 13 || evt.which === 10) { if (evt.key === 'Enter' && !evt.shiftKey) {
evt.preventDefault(); evt.preventDefault();
this.send(); this.send();
} }

View file

@ -245,14 +245,6 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
} else if (msg.type === 'USER_NEWINFO') { } else if (msg.type === 'USER_NEWINFO') {
const userInfo = msg.userInfo; const userInfo = msg.userInfo;
const id = userInfo.userId; const id = userInfo.userId;
// Avoid a race condition when setting colors. If our color was set by a
// query param, ignore our own "new user" message's color value.
if (id === initialUserInfo.userId && initialUserInfo.globalUserColor) {
msg.userInfo.colorId = initialUserInfo.globalUserColor;
}
if (userSet[id]) { if (userSet[id]) {
userSet[id] = userInfo; userSet[id] = userInfo;
callbacks.onUpdateUserInfo(userInfo); callbacks.onUpdateUserInfo(userInfo);
@ -272,7 +264,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
} else if (msg.type === 'CLIENT_MESSAGE') { } else if (msg.type === 'CLIENT_MESSAGE') {
callbacks.onClientMessage(msg.payload); callbacks.onClientMessage(msg.payload);
} else if (msg.type === 'CHAT_MESSAGE') { } else if (msg.type === 'CHAT_MESSAGE') {
chat.addMessage(msg, true, false); chat.addMessage(msg.message, true, false);
} else if (msg.type === 'CHAT_MESSAGES') { } else if (msg.type === 'CHAT_MESSAGES') {
for (let i = msg.messages.length - 1; i >= 0; i--) { for (let i = msg.messages.length - 1; i >= 0; i--) {
chat.addMessage(msg.messages[i], true, true); chat.addMessage(msg.messages[i], true, true);

View file

@ -31,30 +31,7 @@ const Changeset = require('./Changeset');
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
const sanitizeUnicode = (s) => UNorm.nfc(s); const sanitizeUnicode = (s) => UNorm.nfc(s);
// This file is used both in browsers and with cheerio in Node.js (for importing HTML). Cheerio's
// Node-like objects are not 100% API compatible with the DOM specification; the following functions
// abstract away the differences.
// .nodeType works with DOM and cheerio 0.22.0, but cheerio 0.22.0 does not provide the Node.*_NODE
// constants so they cannot be used here.
const isElementNode = (n) => n.nodeType === 1; // Node.ELEMENT_NODE
const isTextNode = (n) => n.nodeType === 3; // Node.TEXT_NODE
// .tagName works with DOM and cheerio 0.22.0, but:
// * With DOM, .tagName is an uppercase string.
// * With cheerio 0.22.0, .tagName is a lowercase string.
// For consistency, this function always returns a lowercase string.
const tagName = (n) => n.tagName && n.tagName.toLowerCase(); const tagName = (n) => n.tagName && n.tagName.toLowerCase();
// .childNodes works with DOM and cheerio 0.22.0, except in cheerio the .childNodes property does
// not exist on text nodes (and maybe other non-element nodes).
const childNodes = (n) => n.childNodes || [];
const getAttribute = (n, a) => {
// .getAttribute() works with DOM but not with cheerio 0.22.0.
if (n.getAttribute != null) return n.getAttribute(a);
// .attribs[] works with cheerio 0.22.0 but not with DOM.
if (n.attribs != null) return n.attribs[a];
return null;
};
// supportedElems are Supported natively within Etherpad and don't require a plugin // supportedElems are Supported natively within Etherpad and don't require a plugin
const supportedElems = new Set([ const supportedElems = new Set([
'author', 'author',
@ -115,9 +92,8 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
attribsBuilder = Changeset.smartOpAssembler(); attribsBuilder = Changeset.smartOpAssembler();
}, },
textOfLine: (i) => textArray[i], textOfLine: (i) => textArray[i],
appendText: (txt, attrString) => { appendText: (txt, attrString = '') => {
textArray[textArray.length - 1] += txt; textArray[textArray.length - 1] += txt;
// dmesg(txt+" / "+attrString);
op.attribs = attrString; op.attribs = attrString;
op.chars = txt.length; op.chars = txt.length;
attribsBuilder.append(op); attribsBuilder.append(op);
@ -147,17 +123,13 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
let selEnd = [-1, -1]; let selEnd = [-1, -1];
const _isEmpty = (node, state) => { const _isEmpty = (node, state) => {
// consider clean blank lines pasted in IE to be empty // consider clean blank lines pasted in IE to be empty
if (childNodes(node).length === 0) return true; if (node.childNodes.length === 0) return true;
if (childNodes(node).length === 1 && if (node.childNodes.length === 1 &&
getAssoc(node, 'shouldBeEmpty') && getAssoc(node, 'shouldBeEmpty') &&
// Note: The .innerHTML property exists on DOM Element objects but not on cheerio's
// Element-like objects (cheerio v0.22.0) so this equality check will always be false.
// Cheerio's Element-like objects have no equivalent to .innerHTML. (Cheerio objects have an
// .html() method, but that isn't accessible here.)
node.innerHTML === '&nbsp;' && node.innerHTML === '&nbsp;' &&
!getAssoc(node, 'unpasted')) { !getAssoc(node, 'unpasted')) {
if (state) { if (state) {
const child = childNodes(node)[0]; const child = node.childNodes[0];
_reachPoint(child, 0, state); _reachPoint(child, 0, state);
_reachPoint(child, 1, state); _reachPoint(child, 1, state);
} }
@ -177,7 +149,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
}; };
const _reachBlockPoint = (nd, idx, state) => { const _reachBlockPoint = (nd, idx, state) => {
if (!isTextNode(nd)) _reachPoint(nd, idx, state); if (nd.nodeType !== nd.TEXT_NODE) _reachPoint(nd, idx, state);
}; };
const _reachPoint = (nd, idx, state) => { const _reachPoint = (nd, idx, state) => {
@ -349,8 +321,8 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
const startLine = lines.length() - 1; const startLine = lines.length() - 1;
_reachBlockPoint(node, 0, state); _reachBlockPoint(node, 0, state);
if (isTextNode(node)) { if (node.nodeType === node.TEXT_NODE) {
const tname = getAttribute(node.parentNode, 'name'); const tname = node.parentNode.getAttribute('name');
const context = {cc: this, state, tname, node, text: node.nodeValue}; const context = {cc: this, state, tname, node, text: node.nodeValue};
// Hook functions may either return a string (deprecated) or modify context.text. If any hook // Hook functions may either return a string (deprecated) or modify context.text. If any hook
// function modifies context.text then all returned strings are ignored. If no hook functions // function modifies context.text then all returned strings are ignored. If no hook functions
@ -407,7 +379,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
cc.startNewLine(state); cc.startNewLine(state);
} }
} }
} else if (isElementNode(node)) { } else if (node.nodeType === node.ELEMENT_NODE) {
const tname = tagName(node) || ''; const tname = tagName(node) || '';
if (tname === 'img') { if (tname === 'img') {
@ -426,7 +398,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
if (tname === 'br') { if (tname === 'br') {
this.breakLine = true; this.breakLine = true;
const tvalue = getAttribute(node, 'value'); const tvalue = node.getAttribute('value');
const [startNewLine = true] = hooks.callAll('collectContentLineBreak', { const [startNewLine = true] = hooks.callAll('collectContentLineBreak', {
cc: this, cc: this,
state, state,
@ -441,8 +413,8 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
} else if (tname === 'script' || tname === 'style') { } else if (tname === 'script' || tname === 'style') {
// ignore // ignore
} else if (!isEmpty) { } else if (!isEmpty) {
let styl = getAttribute(node, 'style'); let styl = node.getAttribute('style');
let cls = getAttribute(node, 'class'); let cls = node.getAttribute('class');
let isPre = (tname === 'pre'); let isPre = (tname === 'pre');
if ((!isPre) && abrowser && abrowser.safari) { if ((!isPre) && abrowser && abrowser.safari) {
isPre = (styl && /\bwhite-space:\s*pre\b/i.exec(styl)); isPre = (styl && /\bwhite-space:\s*pre\b/i.exec(styl));
@ -489,14 +461,14 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
cc.doAttrib(state, 'strikethrough'); cc.doAttrib(state, 'strikethrough');
} }
if (tname === 'ul' || tname === 'ol') { if (tname === 'ul' || tname === 'ol') {
let type = getAttribute(node, 'class'); let type = node.getAttribute('class');
const rr = cls && /(?:^| )list-([a-z]+[0-9]+)\b/.exec(cls); const rr = cls && /(?:^| )list-([a-z]+[0-9]+)\b/.exec(cls);
// lists do not need to have a type, so before we make a wrong guess // lists do not need to have a type, so before we make a wrong guess
// check if we find a better hint within the node's children // check if we find a better hint within the node's children
if (!rr && !type) { if (!rr && !type) {
for (const child of childNodes(node)) { for (const child of node.childNodes) {
if (tagName(child) !== 'ul') continue; if (tagName(child) !== 'ul') continue;
type = getAttribute(child, 'class'); type = child.getAttribute('class');
if (type) break; if (type) break;
} }
} }
@ -504,7 +476,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
type = rr[1]; type = rr[1];
} else { } else {
if (tname === 'ul') { if (tname === 'ul') {
const cls = getAttribute(node, 'class'); const cls = node.getAttribute('class');
if ((type && type.match('indent')) || (cls && cls.match('indent'))) { if ((type && type.match('indent')) || (cls && cls.match('indent'))) {
type = 'indent'; type = 'indent';
} else { } else {
@ -576,7 +548,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
} }
} }
for (const c of childNodes(node)) { for (const c of node.childNodes) {
cc.collectContent(c, state); cc.collectContent(c, state);
} }

View file

@ -61,6 +61,8 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
if (document) { if (document) {
result.node = document.createElement('div'); result.node = document.createElement('div');
// JAWS and NVDA screen reader compatibility. Only needed if in a real browser.
result.node.setAttribute('aria-live', 'assertive');
} else { } else {
result.node = { result.node = {
innerHTML: '', innerHTML: '',
@ -224,7 +226,6 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
}; };
result.prepareForAdd = writeHTML; result.prepareForAdd = writeHTML;
result.finishUpdate = writeHTML; result.finishUpdate = writeHTML;
result.getInnerHTML = () => curHTML || '';
return result; return result;
}; };

View file

@ -108,7 +108,7 @@ linestylefilter.getLineStyleFilter = (lineLength, aline, textAndClassFunc, apool
let nextOp, nextOpClasses; let nextOp, nextOpClasses;
const goNextOp = () => { const goNextOp = () => {
nextOp = attributionIter.next(); nextOp = attributionIter.hasNext() ? attributionIter.next() : Changeset.newOp();
nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs)); nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs));
}; };
goNextOp(); goNextOp();
@ -131,7 +131,7 @@ linestylefilter.getLineStyleFilter = (lineLength, aline, textAndClassFunc, apool
linestylefilter, linestylefilter,
text: txt, text: txt,
class: cls, class: cls,
}, ' ', ' ', ''); });
const disableAuthors = (disableAuthColorForThisLine == null || const disableAuthors = (disableAuthColorForThisLine == null ||
disableAuthColorForThisLine.length === 0) ? false : disableAuthColorForThisLine[0]; disableAuthColorForThisLine.length === 0) ? false : disableAuthColorForThisLine[0];
while (txt.length > 0) { while (txt.length > 0) {

View file

@ -48,8 +48,6 @@ const socketio = require('./socketio');
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
let receivedClientVars = false;
// This array represents all GET-parameters which can be used to change a setting. // This array represents all GET-parameters which can be used to change a setting.
// name: the parameter-name, eg `?noColors=true` => `noColors` // name: the parameter-name, eg `?noColors=true` => `noColors`
// checkVal: the callback is only executed when // checkVal: the callback is only executed when
@ -159,30 +157,17 @@ const getParams = () => {
// Then URL applied stuff // Then URL applied stuff
const params = getUrlVars(); const params = getUrlVars();
for (const setting of getParameters) { for (const setting of getParameters) {
const value = params[setting.name]; const value = params.get(setting.name);
if (value && (value === setting.checkVal || setting.checkVal == null)) { if (value && (value === setting.checkVal || setting.checkVal == null)) {
setting.callback(value); setting.callback(value);
} }
} }
}; };
const getUrlVars = () => { const getUrlVars = () => new URL(window.location.href).searchParams;
const vars = [];
let hash;
const hashes = window.location.href.slice(window.location.href.indexOf('?') + 1).split('&');
for (let i = 0; i < hashes.length; i++) {
hash = hashes[i].split('=');
vars.push(hash[0]);
vars[hash[0]] = hash[1];
}
return vars;
};
const sendClientReady = (isReconnect, messageType) => { const sendClientReady = (isReconnect) => {
messageType = typeof messageType !== 'undefined' ? messageType : 'CLIENT_READY';
let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1); let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1);
// unescape neccesary due to Safari and Opera interpretation of spaces // unescape neccesary due to Safari and Opera interpretation of spaces
padId = decodeURIComponent(padId); padId = decodeURIComponent(padId);
@ -199,13 +184,23 @@ const sendClientReady = (isReconnect, messageType) => {
Cookies.set('token', token, {expires: 60}); Cookies.set('token', token, {expires: 60});
} }
// If known, propagate the display name and color to the server in the CLIENT_READY message. This
// allows the server to include the values in its reply CLIENT_VARS message (which avoids
// initialization race conditions) and in the USER_NEWINFO messages sent to the other users on the
// pad (which enables them to display a user join notification with the correct name).
const params = getUrlVars();
const userInfo = {
colorId: params.get('userColor'),
name: params.get('userName'),
};
const msg = { const msg = {
component: 'pad', component: 'pad',
type: messageType, type: 'CLIENT_READY',
padId, padId,
sessionID: Cookies.get('sessionID'), sessionID: Cookies.get('sessionID'),
token, token,
protocolVersion: 2, userInfo,
}; };
// this is a reconnect, lets tell the server our revisionnumber // this is a reconnect, lets tell the server our revisionnumber
@ -217,7 +212,8 @@ const sendClientReady = (isReconnect, messageType) => {
socket.json.send(msg); socket.json.send(msg);
}; };
const handshake = () => { const handshake = async () => {
let receivedClientVars = false;
let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1); let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1);
// unescape neccesary due to Safari and Opera interpretation of spaces // unescape neccesary due to Safari and Opera interpretation of spaces
padId = decodeURIComponent(padId); padId = decodeURIComponent(padId);
@ -297,63 +293,8 @@ const handshake = () => {
} }
} }
} else if (!receivedClientVars && obj.type === 'CLIENT_VARS') { } else if (!receivedClientVars && obj.type === 'CLIENT_VARS') {
// if we haven't recieved the clientVars yet, then this message should it be
receivedClientVars = true; receivedClientVars = true;
// set some client vars
window.clientVars = obj.data; window.clientVars = obj.data;
// initialize the pad
pad._afterHandshake();
if (clientVars.readonly) {
chat.hide();
$('#myusernameedit').attr('disabled', true);
$('#chatinput').attr('disabled', true);
$('#chaticon').hide();
$('#options-chatandusers').parent().hide();
$('#options-stickychat').parent().hide();
} else if (!settings.hideChat) { $('#chaticon').show(); }
$('body').addClass(clientVars.readonly ? 'readonly' : 'readwrite');
padeditor.ace.callWithAce((ace) => {
ace.ace_setEditable(!clientVars.readonly);
});
// If the LineNumbersDisabled value is set to true then we need to hide the Line Numbers
if (settings.LineNumbersDisabled === true) {
pad.changeViewOption('showLineNumbers', false);
}
// If the noColors value is set to true then we need to
// hide the background colors on the ace spans
if (settings.noColors === true) {
pad.changeViewOption('noColors', true);
}
if (settings.rtlIsTrue === true) {
pad.changeViewOption('rtlIsTrue', true);
}
// If the Monospacefont value is set to true then change it to monospace.
if (settings.useMonospaceFontGlobal === true) {
pad.changeViewOption('padFontFamily', 'monospace');
}
// if the globalUserName value is set we need to tell the server and
// the client about the new authorname
if (settings.globalUserName !== false) {
pad.notifyChangeName(settings.globalUserName); // Notifies the server
pad.myUserInfo.name = settings.globalUserName;
$('#myusernameedit').val(settings.globalUserName); // Updates the current users UI
}
if (settings.globalUserColor !== false && colorutils.isCssHex(settings.globalUserColor)) {
// Add a 'globalUserColor' property to myUserInfo,
// so collabClient knows we have a query parameter.
pad.myUserInfo.globalUserColor = settings.globalUserColor;
pad.notifyChangeColor(settings.globalUserColor); // Updates pad.myUserInfo.colorId
paduserlist.setMyUserInfo(pad.myUserInfo);
}
} else if (obj.disconnect) { } else if (obj.disconnect) {
padconnectionstatus.disconnected(obj.disconnect); padconnectionstatus.disconnected(obj.disconnect);
socket.disconnect(); socket.disconnect();
@ -365,17 +306,49 @@ const handshake = () => {
return; return;
} else { } else {
pad.collabClient.handleMessageFromServer(obj); pad._messageQ.enqueue(obj);
} }
}); });
// Bind the colorpicker
$('#colorpicker').farbtastic({callback: '#mycolorpickerpreview', width: 220}); await Promise.all([
// Bind the read only button new Promise((resolve) => {
$('#readonlyinput').on('click', () => { const h = (obj) => {
padeditbar.setEmbedLinks(); if (obj.accessStatus || obj.type !== 'CLIENT_VARS') return;
}); socket.off('message', h);
resolve();
};
socket.on('message', h);
}),
// This hook is only intended to be used by test code. If a plugin would like to use this hook,
// the hook must first be promoted to officially supported by deleting the leading underscore
// from the name, adding documentation to `doc/api/hooks_client-side.md`, and deleting this
// comment.
hooks.aCallAll('_socketCreated', {socket}),
]);
}; };
/** Defers message handling until setCollabClient() is called with a non-null value. */
class MessageQueue {
constructor() {
this._q = [];
this._cc = null;
}
setCollabClient(cc) {
this._cc = cc;
this.enqueue(); // Flush.
}
enqueue(...msgs) {
if (this._cc == null) {
this._q.push(...msgs);
} else {
while (this._q.length > 0) this._cc.handleMessageFromServer(this._q.shift());
for (const msg of msgs) this._cc.handleMessageFromServer(msg);
}
}
}
const pad = { const pad = {
// don't access these directly from outside this file, except // don't access these directly from outside this file, except
// for debugging // for debugging
@ -385,65 +358,34 @@ const pad = {
initTime: 0, initTime: 0,
clientTimeOffset: null, clientTimeOffset: null,
padOptions: {}, padOptions: {},
_messageQ: new MessageQueue(),
// these don't require init; clientVars should all go through here // these don't require init; clientVars should all go through here
getPadId: () => clientVars.padId, getPadId: () => clientVars.padId,
getClientIp: () => clientVars.clientIp, getClientIp: () => clientVars.clientIp,
getColorPalette: () => clientVars.colorPalette, getColorPalette: () => clientVars.colorPalette,
getIsDebugEnabled: () => clientVars.debugEnabled,
getPrivilege: (name) => clientVars.accountPrivs[name], getPrivilege: (name) => clientVars.accountPrivs[name],
getUserId: () => pad.myUserInfo.userId, getUserId: () => pad.myUserInfo.userId,
getUserName: () => pad.myUserInfo.name, getUserName: () => pad.myUserInfo.name,
userList: () => paduserlist.users(), userList: () => paduserlist.users(),
switchToPad: (padId) => {
let newHref = new RegExp(/.*\/p\/[^/]+/).exec(document.location.pathname) || clientVars.padId;
newHref = newHref[0];
const options = clientVars.padOptions;
if (typeof options !== 'undefined' && options != null) {
const optionArr = [];
$.each(options, (k, v) => {
const str = `${k}=${v}`;
optionArr.push(str);
});
const optionStr = optionArr.join('&');
newHref = `${newHref}?${optionStr}`;
}
// destroy old pad from DOM
// See https://github.com/ether/etherpad-lite/pull/3915
// TODO: Check if Destroying is enough and doesn't leave negative stuff
// See ace.js "editor.destroy" for a reference of how it was done before
$('#editorcontainer').find('iframe')[0].remove();
if (window.history && window.history.pushState) {
$('#chattext p').remove(); // clear the chat messages
window.history.pushState('', '', newHref);
receivedClientVars = false;
sendClientReady(false, 'SWITCH_TO_PAD');
} else {
// fallback
window.location.href = newHref;
}
},
sendClientMessage: (msg) => { sendClientMessage: (msg) => {
pad.collabClient.sendClientMessage(msg); pad.collabClient.sendClientMessage(msg);
}, },
init: () => { init() {
padutils.setupGlobalExceptionHandler(); padutils.setupGlobalExceptionHandler();
$(document).ready(() => { // $(handler), $().ready(handler), $.wait($.ready).then(handler), etc. don't work if handler is
// start the custom js // an async function for some bizarre reason, so the async function is wrapped in a non-async
if (typeof customStart === 'function') customStart(); // eslint-disable-line no-undef // function.
handshake(); $(() => (async () => {
if (window.customStart != null) window.customStart();
// To use etherpad you have to allow cookies. $('#colorpicker').farbtastic({callback: '#mycolorpickerpreview', width: 220});
// This will check if the prefs-cookie is set. $('#readonlyinput').on('click', () => { padeditbar.setEmbedLinks(); });
// Otherwise it shows up a message to the user.
padcookie.init(); padcookie.init();
}); await handshake();
this._afterHandshake();
})());
}, },
_afterHandshake() { _afterHandshake() {
pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp; pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp;
@ -502,7 +444,7 @@ const pad = {
$('#editorcontainer').addClass('initialized'); $('#editorcontainer').addClass('initialized');
hooks.aCallAll('postAceInit', {ace: padeditor.ace, pad}); hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
}; };
// order of inits is important here: // order of inits is important here:
@ -516,6 +458,7 @@ const pad = {
pad.collabClient = getCollabClient( pad.collabClient = getCollabClient(
padeditor.ace, clientVars.collab_client_vars, pad.myUserInfo, padeditor.ace, clientVars.collab_client_vars, pad.myUserInfo,
{colorPalette: pad.getColorPalette()}, pad); {colorPalette: pad.getColorPalette()}, pad);
this._messageQ.setCollabClient(this.collabClient);
pad.collabClient.setOnUserJoin(pad.handleUserJoin); pad.collabClient.setOnUserJoin(pad.handleUserJoin);
pad.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate); pad.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate);
pad.collabClient.setOnUserLeave(pad.handleUserLeave); pad.collabClient.setOnUserLeave(pad.handleUserLeave);
@ -532,7 +475,57 @@ const pad = {
// there are no messages // there are no messages
$('#chatloadmessagesbutton').css('display', 'none'); $('#chatloadmessagesbutton').css('display', 'none');
} }
if (window.clientVars.readonly) {
chat.hide();
$('#myusernameedit').attr('disabled', true);
$('#chatinput').attr('disabled', true);
$('#chaticon').hide();
$('#options-chatandusers').parent().hide();
$('#options-stickychat').parent().hide();
} else if (!settings.hideChat) { $('#chaticon').show(); }
$('body').addClass(window.clientVars.readonly ? 'readonly' : 'readwrite');
padeditor.ace.callWithAce((ace) => {
ace.ace_setEditable(!window.clientVars.readonly);
});
// If the LineNumbersDisabled value is set to true then we need to hide the Line Numbers
if (settings.LineNumbersDisabled === true) {
this.changeViewOption('showLineNumbers', false);
}
// If the noColors value is set to true then we need to
// hide the background colors on the ace spans
if (settings.noColors === true) {
this.changeViewOption('noColors', true);
}
if (settings.rtlIsTrue === true) {
this.changeViewOption('rtlIsTrue', true);
}
// If the Monospacefont value is set to true then change it to monospace.
if (settings.useMonospaceFontGlobal === true) {
this.changeViewOption('padFontFamily', 'RobotoMono');
}
// if the globalUserName value is set we need to tell the server and
// the client about the new authorname
if (settings.globalUserName !== false) {
this.notifyChangeName(settings.globalUserName); // Notifies the server
this.myUserInfo.name = settings.globalUserName;
$('#myusernameedit').val(settings.globalUserName); // Updates the current users UI
}
if (settings.globalUserColor !== false && colorutils.isCssHex(settings.globalUserColor)) {
// Add a 'globalUserColor' property to myUserInfo,
// so collabClient knows we have a query parameter.
this.myUserInfo.globalUserColor = settings.globalUserColor;
this.notifyChangeColor(settings.globalUserColor); // Updates this.myUserInfo.colorId
paduserlist.setMyUserInfo(this.myUserInfo);
}
}, },
dispose: () => { dispose: () => {
padeditor.dispose(); padeditor.dispose();
}, },
@ -610,16 +603,6 @@ const pad = {
pad.handleOptionsChange(opts); pad.handleOptionsChange(opts);
} }
}, },
dmesg: (m) => {
if (pad.getIsDebugEnabled()) {
const djs = $('#djs').get(0);
const wasAtBottom = (djs.scrollTop - (djs.scrollHeight - $(djs).height()) >= -20);
$('#djs').append(`<p>${m}</p>`);
if (wasAtBottom) {
djs.scrollTop = djs.scrollHeight;
}
}
},
handleChannelStateChange: (newState, message) => { handleChannelStateChange: (newState, message) => {
const oldFullyConnected = !!padconnectionstatus.isFullyConnected(); const oldFullyConnected = !!padconnectionstatus.isFullyConnected();
const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting'); const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting');
@ -760,7 +743,5 @@ exports.baseURL = '';
exports.settings = settings; exports.settings = settings;
exports.randomString = randomString; exports.randomString = randomString;
exports.getParams = getParams; exports.getParams = getParams;
exports.getUrlVars = getUrlVars;
exports.handshake = handshake;
exports.pad = pad; exports.pad = pad;
exports.init = init; exports.init = init;

View file

@ -30,182 +30,184 @@ const padsavedrevs = require('./pad_savedrevs');
const _ = require('underscore'); const _ = require('underscore');
require('./vendors/nice-select'); require('./vendors/nice-select');
const ToolbarItem = function (element) { class ToolbarItem {
this.$el = element; constructor(element) {
}; this.$el = element;
ToolbarItem.prototype.getCommand = function () {
return this.$el.attr('data-key');
};
ToolbarItem.prototype.getValue = function () {
if (this.isSelect()) {
return this.$el.find('select').val();
} }
};
ToolbarItem.prototype.setValue = function (val) { getCommand() {
if (this.isSelect()) { return this.$el.attr('data-key');
return this.$el.find('select').val(val);
} }
};
getValue() {
ToolbarItem.prototype.getType = function () { if (this.isSelect()) {
return this.$el.attr('data-type'); return this.$el.find('select').val();
}; }
ToolbarItem.prototype.isSelect = function () {
return this.getType() === 'select';
};
ToolbarItem.prototype.isButton = function () {
return this.getType() === 'button';
};
ToolbarItem.prototype.bind = function (callback) {
const self = this;
if (self.isButton()) {
self.$el.click((event) => {
$(':focus').blur();
callback(self.getCommand(), self);
event.preventDefault();
});
} else if (self.isSelect()) {
self.$el.find('select').change(() => {
callback(self.getCommand(), self);
});
} }
};
setValue(val) {
if (this.isSelect()) {
return this.$el.find('select').val(val);
}
}
const padeditbar = (function () { getType() {
const syncAnimationFn = () => { return this.$el.attr('data-type');
const SYNCING = -100; }
const DONE = 100;
let state = DONE; isSelect() {
const fps = 25; return this.getType() === 'select';
const step = 1 / fps; }
const T_START = -0.5;
const T_FADE = 1.0; isButton() {
const T_GONE = 1.5; return this.getType() === 'button';
const animator = padutils.makeAnimationScheduler(() => { }
if (state === SYNCING || state === DONE) {
return false; bind(callback) {
} else if (state >= T_GONE) { if (this.isButton()) {
state = DONE; this.$el.click((event) => {
$(':focus').blur();
callback(this.getCommand(), this);
event.preventDefault();
});
} else if (this.isSelect()) {
this.$el.find('select').change(() => {
callback(this.getCommand(), this);
});
}
}
}
const syncAnimation = (() => {
const SYNCING = -100;
const DONE = 100;
let state = DONE;
const fps = 25;
const step = 1 / fps;
const T_START = -0.5;
const T_FADE = 1.0;
const T_GONE = 1.5;
const animator = padutils.makeAnimationScheduler(() => {
if (state === SYNCING || state === DONE) {
return false;
} else if (state >= T_GONE) {
state = DONE;
$('#syncstatussyncing').css('display', 'none');
$('#syncstatusdone').css('display', 'none');
return false;
} else if (state < 0) {
state += step;
if (state >= 0) {
$('#syncstatussyncing').css('display', 'none'); $('#syncstatussyncing').css('display', 'none');
$('#syncstatusdone').css('display', 'none'); $('#syncstatusdone').css('display', 'block').css('opacity', 1);
return false;
} else if (state < 0) {
state += step;
if (state >= 0) {
$('#syncstatussyncing').css('display', 'none');
$('#syncstatusdone').css('display', 'block').css('opacity', 1);
}
return true;
} else {
state += step;
if (state >= T_FADE) {
$('#syncstatusdone').css('opacity', (T_GONE - state) / (T_GONE - T_FADE));
}
return true;
} }
}, step * 1000); return true;
return { } else {
syncing: () => { state += step;
state = SYNCING; if (state >= T_FADE) {
$('#syncstatussyncing').css('display', 'block'); $('#syncstatusdone').css('opacity', (T_GONE - state) / (T_GONE - T_FADE));
$('#syncstatusdone').css('display', 'none'); }
}, return true;
done: () => { }
state = T_START; }, step * 1000);
animator.scheduleAnimation(); return {
}, syncing: () => {
}; state = SYNCING;
$('#syncstatussyncing').css('display', 'block');
$('#syncstatusdone').css('display', 'none');
},
done: () => {
state = T_START;
animator.scheduleAnimation();
},
}; };
const syncAnimation = syncAnimationFn(); })();
const self = { exports.padeditbar = new class {
init() { constructor() {
const self = this; this._editbarPosition = 0;
self.dropdowns = []; this.commands = {};
this.dropdowns = [];
}
$('#editbar .editbarbutton').attr('unselectable', 'on'); // for IE init() {
this.enable(); $('#editbar .editbarbutton').attr('unselectable', 'on'); // for IE
$('#editbar [data-key]').each(function () { this.enable();
$(this).unbind('click'); $('#editbar [data-key]').each((i, elt) => {
(new ToolbarItem($(this))).bind((command, item) => { $(elt).unbind('click');
self.triggerCommand(command, item); new ToolbarItem($(elt)).bind((command, item) => {
}); this.triggerCommand(command, item);
}); });
});
$('body:not(#editorcontainerbox)').on('keydown', (evt) => { $('body:not(#editorcontainerbox)').on('keydown', (evt) => {
bodyKeyEvent(evt); this._bodyKeyEvent(evt);
}); });
$('.show-more-icon-btn').click(() => { $('.show-more-icon-btn').click(() => {
$('.toolbar').toggleClass('full-icons'); $('.toolbar').toggleClass('full-icons');
}); });
self.checkAllIconsAreDisplayedInToolbar(); this.checkAllIconsAreDisplayedInToolbar();
$(window).resize(_.debounce(self.checkAllIconsAreDisplayedInToolbar, 100)); $(window).resize(_.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
registerDefaultCommands(self); this._registerDefaultCommands();
hooks.callAll('postToolbarInit', { hooks.callAll('postToolbarInit', {
toolbar: self, toolbar: this,
ace: padeditor.ace, ace: padeditor.ace,
}); });
/* /*
* On safari, the dropdown in the toolbar gets hidden because of toolbar * On safari, the dropdown in the toolbar gets hidden because of toolbar
* overflow:hidden property. This is a bug from Safari: any children with * overflow:hidden property. This is a bug from Safari: any children with
* position:fixed (like the dropdown) should be displayed no matter * position:fixed (like the dropdown) should be displayed no matter
* overflow:hidden on parent * overflow:hidden on parent
*/ */
if (!browser.safari) { if (!browser.safari) {
$('select').niceSelect(); $('select').niceSelect();
} }
// When editor is scrolled, we add a class to style the editbar differently // When editor is scrolled, we add a class to style the editbar differently
$('iframe[name="ace_outer"]').contents().scroll(function () { $('iframe[name="ace_outer"]').contents().scroll((ev) => {
$('#editbar').toggleClass('editor-scrolled', $(this).scrollTop() > 2); $('#editbar').toggleClass('editor-scrolled', $(ev.currentTarget).scrollTop() > 2);
}); });
}, }
isEnabled: () => true, isEnabled() { return true; }
disable: () => { disable() {
$('#editbar').addClass('disabledtoolbar').removeClass('enabledtoolbar'); $('#editbar').addClass('disabledtoolbar').removeClass('enabledtoolbar');
}, }
enable: () => { enable() {
$('#editbar').addClass('enabledtoolbar').removeClass('disabledtoolbar'); $('#editbar').addClass('enabledtoolbar').removeClass('disabledtoolbar');
}, }
commands: {}, registerCommand(cmd, callback) {
registerCommand(cmd, callback) { this.commands[cmd] = callback;
this.commands[cmd] = callback; return this;
return this; }
}, registerDropdownCommand(cmd, dropdown) {
registerDropdownCommand(cmd, dropdown) { dropdown = dropdown || cmd;
dropdown = dropdown || cmd; this.dropdowns.push(dropdown);
self.dropdowns.push(dropdown); this.registerCommand(cmd, () => {
this.registerCommand(cmd, () => { this.toggleDropDown(dropdown);
self.toggleDropDown(dropdown); });
}); }
}, registerAceCommand(cmd, callback) {
registerAceCommand(cmd, callback) { this.registerCommand(cmd, (cmd, ace, item) => {
this.registerCommand(cmd, (cmd, ace, item) => { ace.callWithAce((ace) => {
ace.callWithAce((ace) => { callback(cmd, ace, item);
callback(cmd, ace, item); }, cmd, true);
}, cmd, true); });
}); }
}, triggerCommand(cmd, item) {
triggerCommand(cmd, item) { if (this.isEnabled() && this.commands[cmd]) {
if (self.isEnabled() && this.commands[cmd]) { this.commands[cmd](cmd, padeditor.ace, item);
this.commands[cmd](cmd, padeditor.ace, item); }
} if (padeditor.ace) padeditor.ace.focus();
if (padeditor.ace) padeditor.ace.focus(); }
},
toggleDropDown: (moduleName, cb) => { // cb is deprecated (this function is synchronous so a callback is unnecessary).
toggleDropDown(moduleName, cb = null) {
let cbErr = null;
try {
// do nothing if users are sticked // do nothing if users are sticked
if (moduleName === 'users' && $('#users').hasClass('stickyUsers')) { if (moduleName === 'users' && $('#users').hasClass('stickyUsers')) {
return; return;
@ -216,10 +218,7 @@ const padeditbar = (function () {
// hide all modules and remove highlighting of all buttons // hide all modules and remove highlighting of all buttons
if (moduleName === 'none') { if (moduleName === 'none') {
const returned = false; for (const thisModuleName of this.dropdowns) {
for (let i = 0; i < self.dropdowns.length; i++) {
const thisModuleName = self.dropdowns[i];
// skip the userlist // skip the userlist
if (thisModuleName === 'users') continue; if (thisModuleName === 'users') continue;
@ -233,13 +232,10 @@ const padeditbar = (function () {
module.removeClass('popup-show'); module.removeClass('popup-show');
} }
} }
if (!returned && cb) return cb();
} else { } else {
// hide all modules that are not selected and remove highlighting // hide all modules that are not selected and remove highlighting
// respectively add highlighting to the corresponding button // respectively add highlighting to the corresponding button
for (let i = 0; i < self.dropdowns.length; i++) { for (const thisModuleName of this.dropdowns) {
const thisModuleName = self.dropdowns[i];
const module = $(`#${thisModuleName}`); const module = $(`#${thisModuleName}`);
if (module.hasClass('popup-show')) { if (module.hasClass('popup-show')) {
@ -248,77 +244,74 @@ const padeditbar = (function () {
} else if (thisModuleName === moduleName) { } else if (thisModuleName === moduleName) {
$(`li[data-key=${thisModuleName}] > a`).addClass('selected'); $(`li[data-key=${thisModuleName}] > a`).addClass('selected');
module.addClass('popup-show'); module.addClass('popup-show');
if (cb) {
cb();
}
} }
} }
} }
}, } catch (err) {
setSyncStatus: (status) => { cbErr = err || new Error(err);
if (status === 'syncing') { } finally {
syncAnimation.syncing(); if (cb) Promise.resolve().then(() => cb(cbErr));
} else if (status === 'done') { }
syncAnimation.done(); }
} setSyncStatus(status) {
}, if (status === 'syncing') {
setEmbedLinks: () => { syncAnimation.syncing();
const padUrl = window.location.href.split('?')[0]; } else if (status === 'done') {
const params = '?showControls=true&showChat=true&showLineNumbers=true&useMonospaceFont=false'; syncAnimation.done();
const props = 'width="100%" height="600" frameborder="0"'; }
}
setEmbedLinks() {
const padUrl = window.location.href.split('?')[0];
const params = '?showControls=true&showChat=true&showLineNumbers=true&useMonospaceFont=false';
const props = 'width="100%" height="600" frameborder="0"';
if ($('#readonlyinput').is(':checked')) { if ($('#readonlyinput').is(':checked')) {
const urlParts = padUrl.split('/'); const urlParts = padUrl.split('/');
urlParts.pop(); urlParts.pop();
const readonlyLink = `${urlParts.join('/')}/${clientVars.readOnlyId}`; const readonlyLink = `${urlParts.join('/')}/${clientVars.readOnlyId}`;
$('#embedinput') $('#embedinput')
.val(`<iframe name="embed_readonly" src="${readonlyLink}${params}" ${props}></iframe>`); .val(`<iframe name="embed_readonly" src="${readonlyLink}${params}" ${props}></iframe>`);
$('#linkinput').val(readonlyLink); $('#linkinput').val(readonlyLink);
} else { } else {
$('#embedinput') $('#embedinput')
.val(`<iframe name="embed_readwrite" src="${padUrl}${params}" ${props}></iframe>`); .val(`<iframe name="embed_readwrite" src="${padUrl}${params}" ${props}></iframe>`);
$('#linkinput').val(padUrl); $('#linkinput').val(padUrl);
} }
}, }
checkAllIconsAreDisplayedInToolbar: () => { checkAllIconsAreDisplayedInToolbar() {
// reset style // reset style
$('.toolbar').removeClass('cropped'); $('.toolbar').removeClass('cropped');
$('body').removeClass('mobile-layout'); $('body').removeClass('mobile-layout');
const menu_left = $('.toolbar .menu_left')[0]; const menuLeft = $('.toolbar .menu_left')[0];
// this is approximate, we cannot measure it because on mobile // this is approximate, we cannot measure it because on mobile
// Layout it takes the full width on the bottom of the page // Layout it takes the full width on the bottom of the page
const menuRightWidth = 280; const menuRightWidth = 280;
if (menu_left && menu_left.scrollWidth > $('.toolbar').width() - menuRightWidth || if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width() - menuRightWidth ||
$('.toolbar').width() < 1000) { $('.toolbar').width() < 1000) {
$('body').addClass('mobile-layout'); $('body').addClass('mobile-layout');
} }
if (menu_left && menu_left.scrollWidth > $('.toolbar').width()) { if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width()) {
$('.toolbar').addClass('cropped'); $('.toolbar').addClass('cropped');
} }
}, }
};
let editbarPosition = 0; _bodyKeyEvent(evt) {
const bodyKeyEvent = (evt) => {
// If the event is Alt F9 or Escape & we're already in the editbar menu // If the event is Alt F9 or Escape & we're already in the editbar menu
// Send the users focus back to the pad // Send the users focus back to the pad
if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) { if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) {
if ($(':focus').parents('.toolbar').length === 1) { if ($(':focus').parents('.toolbar').length === 1) {
// If we're in the editbar already.. // If we're in the editbar already..
// Close any dropdowns we have open.. // Close any dropdowns we have open..
padeditbar.toggleDropDown('none'); this.toggleDropDown('none');
// Shift focus away from any drop downs
$(':focus').blur(); // required to do not try to remove!
// Check we're on a pad and not on the timeslider // Check we're on a pad and not on the timeslider
// Or some other window I haven't thought about! // Or some other window I haven't thought about!
if (typeof pad === 'undefined') { if (typeof pad === 'undefined') {
// Timeslider probably.. // Timeslider probably..
// Shift focus away from any drop downs
$(':focus').blur(); // required to do not try to remove!
$('#editorcontainerbox').focus(); // Focus back onto the pad $('#editorcontainerbox').focus(); // Focus back onto the pad
} else { } else {
// Shift focus away from any drop downs
$(':focus').blur(); // required to do not try to remove!
padeditor.ace.focus(); // Sends focus back to pad padeditor.ace.focus(); // Sends focus back to pad
// The above focus doesn't always work in FF, you have to hit enter afterwards // The above focus doesn't always work in FF, you have to hit enter afterwards
evt.preventDefault(); evt.preventDefault();
@ -327,7 +320,7 @@ const padeditbar = (function () {
// Focus on the editbar :) // Focus on the editbar :)
const firstEditbarElement = parent.parent.$('#editbar button').first(); const firstEditbarElement = parent.parent.$('#editbar button').first();
$(this).blur(); $(evt.currentTarget).blur();
firstEditbarElement.focus(); firstEditbarElement.focus();
evt.preventDefault(); evt.preventDefault();
} }
@ -345,10 +338,10 @@ const padeditbar = (function () {
// If a dropdown is visible or we're in an input don't move to the next button // If a dropdown is visible or we're in an input don't move to the next button
if ($('.popup').is(':visible') || evt.target.localName === 'input') return; if ($('.popup').is(':visible') || evt.target.localName === 'input') return;
editbarPosition--; this._editbarPosition--;
// Allow focus to shift back to end of row and start of row // Allow focus to shift back to end of row and start of row
if (editbarPosition === -1) editbarPosition = focusItems.length - 1; if (this._editbarPosition === -1) this._editbarPosition = focusItems.length - 1;
$(focusItems[editbarPosition]).focus(); $(focusItems[this._editbarPosition]).focus();
} }
// On right arrow move to next button in editbar // On right arrow move to next button in editbar
@ -356,97 +349,92 @@ const padeditbar = (function () {
// If a dropdown is visible or we're in an input don't move to the next button // If a dropdown is visible or we're in an input don't move to the next button
if ($('.popup').is(':visible') || evt.target.localName === 'input') return; if ($('.popup').is(':visible') || evt.target.localName === 'input') return;
editbarPosition++; this._editbarPosition++;
// Allow focus to shift back to end of row and start of row // Allow focus to shift back to end of row and start of row
if (editbarPosition >= focusItems.length) editbarPosition = 0; if (this._editbarPosition >= focusItems.length) this._editbarPosition = 0;
$(focusItems[editbarPosition]).focus(); $(focusItems[this._editbarPosition]).focus();
} }
} }
}; }
const aceAttributeCommand = (cmd, ace) => { _registerDefaultCommands() {
ace.ace_toggleAttributeOnSelection(cmd); this.registerDropdownCommand('showusers', 'users');
}; this.registerDropdownCommand('settings');
this.registerDropdownCommand('connectivity');
this.registerDropdownCommand('import_export');
this.registerDropdownCommand('embed');
const registerDefaultCommands = (toolbar) => { this.registerCommand('settings', () => {
toolbar.registerDropdownCommand('showusers', 'users'); this.toggleDropDown('settings');
toolbar.registerDropdownCommand('settings'); $('#options-stickychat').focus();
toolbar.registerDropdownCommand('connectivity');
toolbar.registerDropdownCommand('import_export');
toolbar.registerDropdownCommand('embed');
toolbar.registerCommand('settings', () => {
toolbar.toggleDropDown('settings', () => {
$('#options-stickychat').focus();
});
}); });
toolbar.registerCommand('import_export', () => { this.registerCommand('import_export', () => {
toolbar.toggleDropDown('import_export', () => { this.toggleDropDown('import_export');
// If Import file input exists then focus on it.. // If Import file input exists then focus on it..
if ($('#importfileinput').length !== 0) { if ($('#importfileinput').length !== 0) {
setTimeout(() => { setTimeout(() => {
$('#importfileinput').focus(); $('#importfileinput').focus();
}, 100); }, 100);
} else { } else {
$('.exportlink').first().focus(); $('.exportlink').first().focus();
} }
});
}); });
toolbar.registerCommand('showusers', () => { this.registerCommand('showusers', () => {
toolbar.toggleDropDown('users', () => { this.toggleDropDown('users');
$('#myusernameedit').focus(); $('#myusernameedit').focus();
});
}); });
toolbar.registerCommand('embed', () => { this.registerCommand('embed', () => {
toolbar.setEmbedLinks(); this.setEmbedLinks();
toolbar.toggleDropDown('embed', () => { this.toggleDropDown('embed');
$('#linkinput').focus().select(); $('#linkinput').focus().select();
});
}); });
toolbar.registerCommand('savedRevision', () => { this.registerCommand('savedRevision', () => {
padsavedrevs.saveNow(); padsavedrevs.saveNow();
}); });
toolbar.registerCommand('showTimeSlider', () => { this.registerCommand('showTimeSlider', () => {
document.location = `${document.location.pathname}/timeslider`; document.location = `${document.location.pathname}/timeslider`;
}); });
toolbar.registerAceCommand('bold', aceAttributeCommand); const aceAttributeCommand = (cmd, ace) => {
toolbar.registerAceCommand('italic', aceAttributeCommand); ace.ace_toggleAttributeOnSelection(cmd);
toolbar.registerAceCommand('underline', aceAttributeCommand); };
toolbar.registerAceCommand('strikethrough', aceAttributeCommand); this.registerAceCommand('bold', aceAttributeCommand);
this.registerAceCommand('italic', aceAttributeCommand);
this.registerAceCommand('underline', aceAttributeCommand);
this.registerAceCommand('strikethrough', aceAttributeCommand);
toolbar.registerAceCommand('undo', (cmd, ace) => { this.registerAceCommand('undo', (cmd, ace) => {
ace.ace_doUndoRedo(cmd); ace.ace_doUndoRedo(cmd);
}); });
toolbar.registerAceCommand('redo', (cmd, ace) => { this.registerAceCommand('redo', (cmd, ace) => {
ace.ace_doUndoRedo(cmd); ace.ace_doUndoRedo(cmd);
}); });
toolbar.registerAceCommand('insertunorderedlist', (cmd, ace) => { this.registerAceCommand('insertunorderedlist', (cmd, ace) => {
ace.ace_doInsertUnorderedList(); ace.ace_doInsertUnorderedList();
}); });
toolbar.registerAceCommand('insertorderedlist', (cmd, ace) => { this.registerAceCommand('insertorderedlist', (cmd, ace) => {
ace.ace_doInsertOrderedList(); ace.ace_doInsertOrderedList();
}); });
toolbar.registerAceCommand('indent', (cmd, ace) => { this.registerAceCommand('indent', (cmd, ace) => {
if (!ace.ace_doIndentOutdent(false)) { if (!ace.ace_doIndentOutdent(false)) {
ace.ace_doInsertUnorderedList(); ace.ace_doInsertUnorderedList();
} }
}); });
toolbar.registerAceCommand('outdent', (cmd, ace) => { this.registerAceCommand('outdent', (cmd, ace) => {
ace.ace_doIndentOutdent(true); ace.ace_doIndentOutdent(true);
}); });
toolbar.registerAceCommand('clearauthorship', (cmd, ace) => { this.registerAceCommand('clearauthorship', (cmd, ace) => {
// If we have the whole document selected IE control A has been hit // If we have the whole document selected IE control A has been hit
const rep = ace.ace_getRep(); const rep = ace.ace_getRep();
let doPrompt = false; let doPrompt = false;
@ -459,13 +447,13 @@ const padeditbar = (function () {
} }
} }
/* /*
* NOTICE: This command isn't fired on Control Shift C. * NOTICE: This command isn't fired on Control Shift C.
* I intentionally didn't create duplicate code because if you are hitting * I intentionally didn't create duplicate code because if you are hitting
* Control Shift C we make the assumption you are a "power user" * Control Shift C we make the assumption you are a "power user"
* and as such we assume you don't need the prompt to bug you each time! * and as such we assume you don't need the prompt to bug you each time!
* This does make wonder if it's worth having a checkbox to avoid being * This does make wonder if it's worth having a checkbox to avoid being
* prompted again but that's probably overkill for this contribution. * prompted again but that's probably overkill for this contribution.
*/ */
// if we don't have any text selected, we have a caret or we have already said to prompt // if we don't have any text selected, we have a caret or we have already said to prompt
if ((!(rep.selStart && rep.selEnd)) || ace.ace_isCaret() || doPrompt) { if ((!(rep.selStart && rep.selEnd)) || ace.ace_isCaret() || doPrompt) {
@ -479,19 +467,15 @@ const padeditbar = (function () {
} }
}); });
toolbar.registerCommand('timeslider_returnToPad', (cmd) => { this.registerCommand('timeslider_returnToPad', (cmd) => {
if (document.referrer.length > 0 && if (document.referrer.length > 0 &&
document.referrer.substring(document.referrer.lastIndexOf('/') - 1, document.referrer.substring(document.referrer.lastIndexOf('/') - 1,
document.referrer.lastIndexOf('/')) === 'p') { document.referrer.lastIndexOf('/')) === 'p') {
document.location = document.referrer; document.location = document.referrer;
} else { } else {
document.location = document.location.href document.location = document.location.href
.substring(0, document.location.href.lastIndexOf('/')); .substring(0, document.location.href.lastIndexOf('/'));
} }
}); });
}; }
}();
return self;
}());
exports.padeditbar = padeditbar;

View file

@ -49,9 +49,6 @@ const padeditor = (() => {
}); });
exports.focusOnLine(self.ace); exports.focusOnLine(self.ace);
self.ace.setProperty('wraps', true); self.ace.setProperty('wraps', true);
if (pad.getIsDebugEnabled()) {
self.ace.setProperty('dmesg', pad.dmesg);
}
self.initViewOptions(); self.initViewOptions();
self.setViewOptions(initialViewOptions); self.setViewOptions(initialViewOptions);
// view bar // view bar

View file

@ -67,7 +67,7 @@ const padimpexp = (() => {
importErrorMessage(message); importErrorMessage(message);
} else { } else {
$('#import_export').removeClass('popup-show'); $('#import_export').removeClass('popup-show');
if (directDatabaseAccess) pad.switchToPad(clientVars.padId); if (directDatabaseAccess) window.location.reload();
} }
$('#importsubmitinput').removeAttr('disabled').val(html10n.get('pad.impexp.importbutton')); $('#importsubmitinput').removeAttr('disabled').val(html10n.get('pad.impexp.importbutton'));
window.setTimeout(() => $('#importfileinput').removeAttr('disabled'), 0); window.setTimeout(() => $('#importfileinput').removeAttr('disabled'), 0);

View file

@ -32,15 +32,14 @@ const padmodals = (() => {
pad = _pad; pad = _pad;
}, },
showModal: (messageId) => { showModal: (messageId) => {
padeditbar.toggleDropDown('none', () => { padeditbar.toggleDropDown('none');
$('#connectivity .visible').removeClass('visible'); $('#connectivity .visible').removeClass('visible');
$(`#connectivity .${messageId}`).addClass('visible'); $(`#connectivity .${messageId}`).addClass('visible');
const $modal = $(`#connectivity .${messageId}`); const $modal = $(`#connectivity .${messageId}`);
automaticReconnect.showCountDownTimerToReconnectOnModal($modal, pad); automaticReconnect.showCountDownTimerToReconnectOnModal($modal, pad);
padeditbar.toggleDropDown('connectivity'); padeditbar.toggleDropDown('connectivity');
});
}, },
showOverlay: () => { showOverlay: () => {
// Prevent the user to interact with the toolbar. Useful when user is disconnected for example // Prevent the user to interact with the toolbar. Useful when user is disconnected for example

View file

@ -60,10 +60,10 @@ const wordCharRegex = new RegExp(`[${[
const urlRegex = (() => { const urlRegex = (() => {
// TODO: wordCharRegex matches many characters that are not permitted in URIs. Are they included // TODO: wordCharRegex matches many characters that are not permitted in URIs. Are they included
// here as an attempt to support IRIs? (See https://tools.ietf.org/html/rfc3987.) // here as an attempt to support IRIs? (See https://tools.ietf.org/html/rfc3987.)
const urlChar = `[-:@_.,~%+/?=&#!;()$'*${wordCharRegex.source.slice(1, -1)}]`; const urlChar = `[-:@_.,~%+/?=&#!;()\\[\\]$'*${wordCharRegex.source.slice(1, -1)}]`;
// Matches a single character that should not be considered part of the URL if it is the last // Matches a single character that should not be considered part of the URL if it is the last
// character that matches urlChar. // character that matches urlChar.
const postUrlPunct = '[:.,;?!)\'*]'; const postUrlPunct = '[:.,;?!)\\]\'*]';
// Schemes that must be followed by :// // Schemes that must be followed by ://
const withAuth = `(?:${[ const withAuth = `(?:${[
'(?:x-)?man', '(?:x-)?man',
@ -89,6 +89,25 @@ const urlRegex = (() => {
})(); })();
const padutils = { const padutils = {
/**
* Prints a warning message followed by a stack trace (to make it easier to figure out what code
* is using the deprecated function).
*
* Most browsers include UI widget to examine the stack at the time of the warning, but this
* includes the stack in the log message for a couple of reasons:
* - This makes it possible to see the stack if the code runs in Node.js.
* - Users are more likely to paste the stack in bug reports they might file.
*
* @param {...*} args - Passed to `console.warn`, with a stack trace appended.
*/
warnWithStack: (...args) => {
const err = new Error();
if (Error.captureStackTrace) Error.captureStackTrace(err, padutils.warnWithStack);
err.name = '';
if (err.stack) args.push(err.stack);
console.warn(...args);
},
escapeHtml: (x) => Security.escapeHTML(String(x)), escapeHtml: (x) => Security.escapeHTML(String(x)),
uniqueId: () => { uniqueId: () => {
const pad = require('./pad').pad; // Sidestep circular dependency const pad = require('./pad').pad; // Sidestep circular dependency
@ -296,6 +315,7 @@ const padutils = {
let globalExceptionHandler = null; let globalExceptionHandler = null;
padutils.setupGlobalExceptionHandler = () => { padutils.setupGlobalExceptionHandler = () => {
if (globalExceptionHandler == null) { if (globalExceptionHandler == null) {
require('./vendors/gritter');
globalExceptionHandler = (e) => { globalExceptionHandler = (e) => {
let type; let type;
let err; let err;
@ -382,17 +402,18 @@ const inThirdPartyIframe = () => {
// This file is included from Node so that it can reuse randomString, but Node doesn't have a global // This file is included from Node so that it can reuse randomString, but Node doesn't have a global
// window object. // window object.
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
exports.Cookies = require('js-cookie/src/js.cookie'); exports.Cookies = require('js-cookie/dist/js.cookie').withAttributes({
// Use `SameSite=Lax`, unless Etherpad is embedded in an iframe from another site in which case // Use `SameSite=Lax`, unless Etherpad is embedded in an iframe from another site in which case
// use `SameSite=None`. For iframes from another site, only `None` has a chance of working // use `SameSite=None`. For iframes from another site, only `None` has a chance of working
// because the cookies are third-party (not same-site). Many browsers/users block third-party // because the cookies are third-party (not same-site). Many browsers/users block third-party
// cookies, but maybe blocked is better than definitely blocked (which would happen with `Lax` // cookies, but maybe blocked is better than definitely blocked (which would happen with `Lax`
// or `Strict`). Note: `None` will not work unless secure is true. // or `Strict`). Note: `None` will not work unless secure is true.
// //
// `Strict` is not used because it has few security benefits but significant usability drawbacks // `Strict` is not used because it has few security benefits but significant usability drawbacks
// vs. `Lax`. See https://stackoverflow.com/q/41841880 for discussion. // vs. `Lax`. See https://stackoverflow.com/q/41841880 for discussion.
exports.Cookies.defaults.sameSite = inThirdPartyIframe() ? 'None' : 'Lax'; sameSite: inThirdPartyIframe() ? 'None' : 'Lax',
exports.Cookies.defaults.secure = window.location.protocol === 'https:'; secure: window.location.protocol === 'https:',
});
} }
exports.randomString = randomString; exports.randomString = randomString;
exports.padutils = padutils; exports.padutils = padutils;

View file

@ -72,19 +72,6 @@ exports.formatHooks = (hookSetName, html) => {
return lines.join('\n'); return lines.join('\n');
}; };
const callInit = async () => {
await Promise.all(Object.keys(defs.plugins).map(async (pluginName) => {
const plugin = defs.plugins[pluginName];
const epInit = path.join(plugin.package.path, '.ep_initialized');
try {
await fs.stat(epInit);
} catch (err) {
await fs.writeFile(epInit, 'done');
await hooks.aCallAll(`init_${pluginName}`, {});
}
}));
};
exports.pathNormalization = (part, hookFnName, hookName) => { exports.pathNormalization = (part, hookFnName, hookName) => {
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'.
@ -111,7 +98,7 @@ exports.update = async () => {
defs.parts = sortParts(parts); defs.parts = sortParts(parts);
defs.hooks = pluginUtils.extractHooks(defs.parts, 'hooks', exports.pathNormalization); defs.hooks = pluginUtils.extractHooks(defs.parts, 'hooks', exports.pathNormalization);
defs.loaded = true; defs.loaded = true;
await callInit(); await Promise.all(Object.keys(defs.plugins).map((p) => hooks.aCallAll(`init_${p}`, {})));
}; };
exports.getPackages = async () => { exports.getPackages = async () => {

View file

@ -291,7 +291,6 @@ class SkipList {
if (end < 0) end = 0; if (end < 0) end = 0;
if (end > this._keyToNodeMap.size) end = this._keyToNodeMap.size; if (end > this._keyToNodeMap.size) end = this._keyToNodeMap.size;
window.dmesg(String([start, end, this._keyToNodeMap.size]));
if (end <= start) return []; if (end <= start) return [];
let n = this.atIndex(start); let n = this.atIndex(start);
const array = [n]; const array = [n];

View file

@ -29,11 +29,13 @@ require('./vendors/jquery');
const Cookies = require('./pad_utils').Cookies; const Cookies = require('./pad_utils').Cookies;
const randomString = require('./pad_utils').randomString; const randomString = require('./pad_utils').randomString;
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
const padutils = require('./pad_utils').padutils;
const socketio = require('./socketio'); const socketio = require('./socketio');
let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider; let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider;
const init = () => { const init = () => {
padutils.setupGlobalExceptionHandler();
$(document).ready(() => { $(document).ready(() => {
// start the custom js // start the custom js
if (typeof customStart === 'function') customStart(); // eslint-disable-line no-undef if (typeof customStart === 'function') customStart(); // eslint-disable-line no-undef
@ -100,7 +102,6 @@ const sendSocketMsg = (type, data) => {
padId, padId,
token, token,
sessionID: Cookies.get('sessionID'), sessionID: Cookies.get('sessionID'),
protocolVersion: 2,
}); });
}; };

View file

@ -55,7 +55,6 @@ const undoModule = (() => {
e.elementType = UNDOABLE_EVENT; e.elementType = UNDOABLE_EVENT;
stackElements.push(e); stackElements.push(e);
numUndoableEvents++; numUndoableEvents++;
// dmesg("pushEvent backset: "+event.backset);
}; };
const pushExternalChange = (cs) => { const pushExternalChange = (cs) => {
@ -207,7 +206,6 @@ const undoModule = (() => {
const merge = _mergeChangesets(event.backset, topEvent.backset); const merge = _mergeChangesets(event.backset, topEvent.backset);
if (merge) { if (merge) {
topEvent.backset = merge; topEvent.backset = merge;
// dmesg("reportEvent merge: "+merge);
applySelectionToTop(); applySelectionToTop();
merged = true; merged = true;
} }

View file

@ -5,11 +5,10 @@
; ;
%> %>
<!doctype html> <!doctype html>
<% e.begin_block("htmlHead"); %>
<html class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>"> <html class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
<% e.end_block(); %> <head>
<% e.begin_block("htmlHead"); %>
<% e.end_block(); %>
<title><%=settings.title%></title> <title><%=settings.title%></title>
<script> <script>
/* /*
@ -34,6 +33,7 @@
for the JavaScript code in this page.| for the JavaScript code in this page.|
*/ */
</script> </script>
<script src="../static/js/basic_error_handler.js?v=<%=settings.randomVersionString%>"></script>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
@ -54,9 +54,8 @@
<link rel="localizations" type="application/l10n+json" href="../locales.json" /> <link rel="localizations" type="application/l10n+json" href="../locales.json" />
<script type="text/javascript" src="../static/js/vendors/html10n.js?v=<%=settings.randomVersionString%>"></script> <script type="text/javascript" src="../static/js/vendors/html10n.js?v=<%=settings.randomVersionString%>"></script>
<script type="text/javascript" src="../static/js/l10n.js?v=<%=settings.randomVersionString%>"></script> <script type="text/javascript" src="../static/js/l10n.js?v=<%=settings.randomVersionString%>"></script>
</head>
<!-- head and body had been removed intentionally --> <body>
<% e.begin_block("body"); %> <% e.begin_block("body"); %>
<!-----------------------------> <!----------------------------->
@ -388,7 +387,7 @@
</div> </div>
<div id="chatinputbox"> <div id="chatinputbox">
<form> <form>
<input id="chatinput" type="text" maxlength="999" data-l10n-id="pad.chat.writeMessage.placeholder"> <textarea id="chatinput" maxlength="999" data-l10n-id="pad.chat.writeMessage.placeholder"></textarea>
</form> </form>
</div> </div>
</div> </div>
@ -443,27 +442,6 @@
<% e.begin_block("scripts"); %> <% e.begin_block("scripts"); %>
<script type="text/javascript" src="../static/js/require-kernel.js?v=<%=settings.randomVersionString%>"></script> <script type="text/javascript" src="../static/js/require-kernel.js?v=<%=settings.randomVersionString%>"></script>
<script type="text/javascript">
// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt
(function() {
// Display errors on page load to the user
// (Gets overridden by padutils.setupGlobalExceptionHandler)
const originalHandler = window.onerror;
window.onerror = function(msg, url, line) {
const box = document.getElementById('editorloadingbox');
const cleanMessage = msg.replace(/[^0-9a-zA-Z=\.?&:\/]+/,'');
const cleanSource = url.replace(/[^0-9a-zA-Z=\.?&:\/]+/,'');
const cleanLine = parseInt(line);
box.innerText = `An error occurred while loading the pad\n${cleanMessage} in
${cleanSource} at line ${cleanLine}`
// call original error handler
if(typeof(originalHandler) == 'function') originalHandler.call(null, arguments);
};
})();
// @license-end
</script>
<script type="text/javascript" src="../socket.io/socket.io.js?v=<%=settings.randomVersionString%>"></script> <script type="text/javascript" src="../socket.io/socket.io.js?v=<%=settings.randomVersionString%>"></script>
<!-- Include base packages manually (this help with debugging) --> <!-- Include base packages manually (this help with debugging) -->
@ -500,6 +478,10 @@
plugins.baseURL = baseURL; plugins.baseURL = baseURL;
plugins.update(function () { plugins.update(function () {
// Mechanism for tests to register hook functions (install fake plugins).
window._postPluginUpdateForTestingDone = false;
if (window._postPluginUpdateForTesting != null) window._postPluginUpdateForTesting();
window._postPluginUpdateForTestingDone = true;
// Call documentReady hook // Call documentReady hook
$(function() { $(function() {
hooks.aCallAll('documentReady'); hooks.aCallAll('documentReady');
@ -522,4 +504,5 @@
</script> </script>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div> <div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
<% e.end_block(); %> <% e.end_block(); %>
</body>
</html> </html>

View file

@ -4,31 +4,32 @@
%> %>
<!doctype html> <!doctype html>
<html class="pad <%=settings.skinVariants%>"> <html class="pad <%=settings.skinVariants%>">
<title data-l10n-id="timeslider.pageTitle" data-l10n-args='{ "appTitle": "<%=settings.title%>" }'><%=settings.title%> Timeslider</title>
<script>
/*
|@licstart The following is the entire license notice for the
JavaScript code in this page.|
Copyright 2011 Peter Martischka, Primary Technology.
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.
|@licend The above is the entire license notice
for the JavaScript code in this page.|
*/
</script>
<head> <head>
<title data-l10n-id="timeslider.pageTitle" data-l10n-args='{ "appTitle": "<%=settings.title%>" }'><%=settings.title%> Timeslider</title>
<script>
/*
|@licstart The following is the entire license notice for the
JavaScript code in this page.|
Copyright 2011 Peter Martischka, Primary Technology.
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.
|@licend The above is the entire license notice
for the JavaScript code in this page.|
*/
</script>
<script src="../../static/js/basic_error_handler.js?v=<%=settings.randomVersionString%>"></script>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer"> <meta name="referrer" content="no-referrer">

View file

@ -1,9 +1,11 @@
'use strict'; 'use strict';
const apiHandler = require('../../node/handler/APIHandler'); const apiHandler = require('../../node/handler/APIHandler');
const io = require('socket.io-client');
const log4js = require('log4js'); const log4js = require('log4js');
const process = require('process'); const process = require('process');
const server = require('../../node/server'); const server = require('../../node/server');
const setCookieParser = require('set-cookie-parser');
const settings = require('../../node/utils/Settings'); const settings = require('../../node/utils/Settings');
const supertest = require('supertest'); const supertest = require('supertest');
const webaccess = require('../../node/hooks/express/webaccess'); const webaccess = require('../../node/hooks/express/webaccess');
@ -17,7 +19,8 @@ exports.baseUrl = null;
exports.httpServer = null; exports.httpServer = null;
exports.logger = log4js.getLogger('test'); exports.logger = log4js.getLogger('test');
const logLevel = exports.logger.level; const logger = exports.logger;
const logLevel = logger.level;
// Mocha doesn't monitor unhandled Promise rejections, so convert them to uncaught exceptions. // Mocha doesn't monitor unhandled Promise rejections, so convert them to uncaught exceptions.
// https://github.com/mochajs/mocha/issues/2640 // https://github.com/mochajs/mocha/issues/2640
@ -34,10 +37,10 @@ exports.init = async function () {
agentPromise = new Promise((resolve) => { agentResolve = resolve; }); agentPromise = new Promise((resolve) => { agentResolve = resolve; });
if (!logLevel.isLessThanOrEqualTo(log4js.levels.DEBUG)) { if (!logLevel.isLessThanOrEqualTo(log4js.levels.DEBUG)) {
exports.logger.warn('Disabling non-test logging for the duration of the test. ' + logger.warn('Disabling non-test logging for the duration of the test. ' +
'To enable non-test logging, change the loglevel setting to DEBUG.'); 'To enable non-test logging, change the loglevel setting to DEBUG.');
log4js.setGlobalLogLevel(log4js.levels.OFF); log4js.setGlobalLogLevel(log4js.levels.OFF);
exports.logger.setLevel(logLevel); logger.setLevel(logLevel);
} }
// Note: This is only a shallow backup. // Note: This is only a shallow backup.
@ -49,7 +52,7 @@ exports.init = async function () {
settings.commitRateLimiting = {duration: 0.001, points: 1e6}; settings.commitRateLimiting = {duration: 0.001, points: 1e6};
exports.httpServer = await server.start(); exports.httpServer = await server.start();
exports.baseUrl = `http://localhost:${exports.httpServer.address().port}`; exports.baseUrl = `http://localhost:${exports.httpServer.address().port}`;
exports.logger.debug(`HTTP server at ${exports.baseUrl}`); logger.debug(`HTTP server at ${exports.baseUrl}`);
// Create a supertest user agent for the HTTP server. // Create a supertest user agent for the HTTP server.
exports.agent = supertest(exports.baseUrl); exports.agent = supertest(exports.baseUrl);
// Speed up authn tests. // Speed up authn tests.
@ -67,3 +70,117 @@ exports.init = async function () {
agentResolve(exports.agent); agentResolve(exports.agent);
return exports.agent; return exports.agent;
}; };
/**
* Waits for the next named socket.io event. Rejects if there is an error event while waiting
* (unless waiting for that error event).
*
* @param {io.Socket} socket - The socket.io Socket object to listen on.
* @param {string} event - The socket.io Socket event to listen for.
* @returns The argument(s) passed to the event handler.
*/
exports.waitForSocketEvent = async (socket, event) => {
const errorEvents = [
'error',
'connect_error',
'connect_timeout',
'reconnect_error',
'reconnect_failed',
];
const handlers = new Map();
let cancelTimeout;
try {
const timeoutP = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`timed out waiting for ${event} event`));
cancelTimeout = () => {};
}, 1000);
cancelTimeout = () => {
clearTimeout(timeout);
resolve();
cancelTimeout = () => {};
};
});
const errorEventP = Promise.race(errorEvents.map((event) => new Promise((resolve, reject) => {
handlers.set(event, (errorString) => {
logger.debug(`socket.io ${event} event: ${errorString}`);
reject(new Error(errorString));
});
})));
const eventP = new Promise((resolve) => {
// This will overwrite one of the above handlers if the user is waiting for an error event.
handlers.set(event, (...args) => {
logger.debug(`socket.io ${event} event`);
if (args.length > 1) return resolve(args);
resolve(args[0]);
});
});
for (const [event, handler] of handlers) socket.on(event, handler);
// timeoutP and errorEventP are guaranteed to never resolve here (they can only reject), so the
// Promise returned by Promise.race() is guaranteed to resolve to the eventP value (if
// the event arrives).
return await Promise.race([timeoutP, errorEventP, eventP]);
} finally {
cancelTimeout();
for (const [event, handler] of handlers) socket.off(event, handler);
}
};
/**
* Establishes a new socket.io connection.
*
* @param {object} [res] - Optional HTTP response object. The cookies from this response's
* `set-cookie` header(s) are passed to the server when opening the socket.io connection. If
* nullish, no cookies are passed to the server.
* @returns {io.Socket} A socket.io client Socket object.
*/
exports.connect = async (res = null) => {
// Convert the `set-cookie` header(s) into a `cookie` header.
const resCookies = (res == null) ? {} : setCookieParser.parse(res, {map: true});
const reqCookieHdr = Object.entries(resCookies).map(
([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`).join('; ');
logger.debug('socket.io connecting...');
let padId = null;
if (res) {
padId = res.req.path.split('/p/')[1];
}
const socket = io(`${exports.baseUrl}/`, {
forceNew: true, // Different tests will have different query parameters.
path: '/socket.io',
// socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the
// express_sid cookie must be passed as a query parameter.
query: {cookie: reqCookieHdr, padId},
});
try {
await exports.waitForSocketEvent(socket, 'connect');
} catch (e) {
socket.close();
throw e;
}
logger.debug('socket.io connected');
return socket;
};
/**
* Helper function to exchange CLIENT_READY+CLIENT_VARS messages for the named pad.
*
* @param {io.Socket} socket - Connected socket.io Socket object.
* @param {string} padId - Which pad to join.
* @returns The CLIENT_VARS message from the server.
*/
exports.handshake = async (socket, padId) => {
logger.debug('sending CLIENT_READY...');
socket.send({
component: 'pad',
type: 'CLIENT_READY',
padId,
sessionID: null,
token: 't.12345',
});
logger.debug('waiting for CLIENT_VARS response...');
const msg = await exports.waitForSocketEvent(socket, 'message');
logger.debug('received CLIENT_VARS message');
return msg;
};

View file

@ -6,8 +6,10 @@
* TODO: maybe unify those two files and merge in a single one. * TODO: maybe unify those two files and merge in a single one.
*/ */
const assert = require('assert').strict;
const common = require('../../common'); const common = require('../../common');
const fs = require('fs'); const fs = require('fs');
const fsp = fs.promises;
let agent; let agent;
const apiKey = common.apiKey; const apiKey = common.apiKey;
@ -19,80 +21,52 @@ const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?api
describe(__filename, function () { describe(__filename, function () {
before(async function () { agent = await common.init(); }); before(async function () { agent = await common.init(); });
describe('Connectivity For Character Encoding', function () { describe('Sanity checks', function () {
it('can connect', function (done) { it('can connect', async function () {
this.timeout(250); await agent.get('/api/')
agent.get('/api/') .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/);
.expect(200, done);
}); });
});
describe('API Versioning', function () { it('finds the version tag', async function () {
this.timeout(150); const res = await agent.get('/api/')
it('finds the version tag', function (done) { .expect(200);
agent.get('/api/') apiVersion = res.body.currentVersion;
.expect((res) => { assert(apiVersion);
apiVersion = res.body.currentVersion;
if (!res.body.currentVersion) throw new Error('No version set in API');
return;
})
.expect(200, done);
}); });
});
describe('Permission', function () { it('errors with invalid APIKey', async function () {
it('errors with invalid APIKey', function (done) {
this.timeout(150);
// This is broken because Etherpad doesn't handle HTTP codes properly see #2343 // This is broken because Etherpad doesn't handle HTTP codes properly see #2343
// If your APIKey is password you deserve to fail all tests anyway // If your APIKey is password you deserve to fail all tests anyway
const permErrorURL = `/api/${apiVersion}/createPad?apikey=password&padID=test`; await agent.get(`/api/${apiVersion}/createPad?apikey=password&padID=test`)
agent.get(permErrorURL) .expect(401);
.expect(401, done);
}); });
}); });
describe('createPad', function () { describe('Tests', function () {
it('creates a new Pad', function (done) { it('creates a new Pad', async function () {
this.timeout(150); const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
agent.get(`${endPoint('createPad')}&padID=${testPadId}`) .expect(200)
.expect((res) => { .expect('Content-Type', /json/);
if (res.body.code !== 0) throw new Error('Unable to create new Pad'); assert.equal(res.body.code, 0);
});
it('Sets the HTML of a Pad attempting to weird utf8 encoded content', async function () {
const res = await agent.post(endPoint('setHTML'))
.send({
padID: testPadId,
html: await fsp.readFile('tests/backend/specs/api/emojis.html', 'utf8'),
}) })
.expect('Content-Type', /json/) .expect(200)
.expect(200, done); .expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
}); });
});
describe('setHTML', function () { it('get the HTML of Pad with emojis', async function () {
it('Sets the HTML of a Pad attempting to weird utf8 encoded content', function (done) { const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`)
this.timeout(1000); .expect(200)
fs.readFile('tests/backend/specs/api/emojis.html', 'utf8', (err, html) => { .expect('Content-Type', /json/);
agent.post(endPoint('setHTML')) assert.match(res.body.data.html, /&#127484/);
.send({
padID: testPadId,
html,
})
.expect((res) => {
if (res.body.code !== 0) throw new Error("Can't set HTML properly");
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
describe('getHTML', function () {
it('get the HTML of Pad with emojis', function (done) {
this.timeout(400);
agent.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.data.html.indexOf('&#127484') === -1) {
throw new Error('Unable to get the HTML');
}
})
.expect('Content-Type', /json/)
.expect(200, done);
}); });
}); });
}); });

View file

@ -39,7 +39,6 @@ describe(__filename, function () {
*/ */
describe('createPad', function () { describe('createPad', function () {
this.timeout(400);
it('creates a new Pad', function (done) { it('creates a new Pad', function (done) {
agent.get(`${endPoint('createPad')}&padID=${padID}`) agent.get(`${endPoint('createPad')}&padID=${padID}`)
.expect((res) => { .expect((res) => {
@ -51,7 +50,6 @@ describe(__filename, function () {
}); });
describe('createAuthor', function () { describe('createAuthor', function () {
this.timeout(100);
it('Creates an author with a name set', function (done) { it('Creates an author with a name set', function (done) {
agent.get(endPoint('createAuthor')) agent.get(endPoint('createAuthor'))
.expect((res) => { .expect((res) => {
@ -66,7 +64,6 @@ describe(__filename, function () {
}); });
describe('appendChatMessage', function () { describe('appendChatMessage', function () {
this.timeout(100);
it('Adds a chat message to the pad', function (done) { it('Adds a chat message to the pad', function (done) {
agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` + agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
`&authorID=${authorID}&time=${timestamp}`) `&authorID=${authorID}&time=${timestamp}`)
@ -80,7 +77,6 @@ describe(__filename, function () {
describe('getChatHead', function () { describe('getChatHead', function () {
this.timeout(100);
it('Gets the head of chat', function (done) { it('Gets the head of chat', function (done) {
agent.get(`${endPoint('getChatHead')}&padID=${padID}`) agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
.expect((res) => { .expect((res) => {
@ -94,7 +90,6 @@ describe(__filename, function () {
}); });
describe('getChatHistory', function () { describe('getChatHistory', function () {
this.timeout(40);
it('Gets Chat History of a Pad', function (done) { it('Gets Chat History of a Pad', function (done) {
agent.get(`${endPoint('getChatHistory')}&padID=${padID}`) agent.get(`${endPoint('getChatHistory')}&padID=${padID}`)
.expect((res) => { .expect((res) => {

View file

@ -31,7 +31,6 @@ describe(__filename, function () {
describe('Connectivity', function () { describe('Connectivity', function () {
it('can connect', async function () { it('can connect', async function () {
this.timeout(250);
await agent.get('/api/') await agent.get('/api/')
.expect(200) .expect(200)
.expect('Content-Type', /json/); .expect('Content-Type', /json/);
@ -40,7 +39,6 @@ describe(__filename, function () {
describe('API Versioning', function () { describe('API Versioning', function () {
it('finds the version tag', async function () { it('finds the version tag', async function () {
this.timeout(250);
await agent.get('/api/') await agent.get('/api/')
.expect(200) .expect(200)
.expect((res) => assert(res.body.currentVersion)); .expect((res) => assert(res.body.currentVersion));
@ -96,7 +94,6 @@ describe(__filename, function () {
}); });
it('creates a new Pad, imports content to it, checks that content', async function () { it('creates a new Pad, imports content to it, checks that content', async function () {
this.timeout(500);
await agent.get(`${endPoint('createPad')}&padID=${testPadId}`) await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -109,28 +106,80 @@ describe(__filename, function () {
.expect((res) => assert.equal(res.body.data.text, padText.toString())); .expect((res) => assert.equal(res.body.data.text, padText.toString()));
}); });
for (const authn of [false, true]) { describe('export from read-only pad ID', function () {
it(`can export from read-only pad ID, authn ${authn}`, async function () { let readOnlyId;
this.timeout(250);
settings.requireAuthentication = authn; // This ought to be before(), but it must run after the top-level beforeEach() above.
const get = (ep) => { beforeEach(async function () {
let req = agent.get(ep); if (readOnlyId != null) return;
if (authn) req = req.auth('user', 'user-password'); await agent.post(`/p/${testPadId}/import`)
return req.expect(200); .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
}; .expect(200);
const ro = await get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`) const res = await agent.get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`)
.expect((res) => assert.ok(JSON.parse(res.text).data.readOnlyID)); .expect(200)
const readOnlyId = JSON.parse(ro.text).data.readOnlyID; .expect('Content-Type', /json/)
await get(`/p/${readOnlyId}/export/html`) .expect((res) => assert.equal(res.body.code, 0));
.expect((res) => assert(res.text.indexOf('This is the') !== -1)); readOnlyId = res.body.data.readOnlyID;
await get(`/p/${readOnlyId}/export/txt`)
.expect((res) => assert(res.text.indexOf('This is the') !== -1));
}); });
}
for (const authn of [false, true]) {
describe(`requireAuthentication = ${authn}`, function () {
// This ought to be before(), but it must run after the top-level beforeEach() above.
beforeEach(async function () {
settings.requireAuthentication = authn;
});
for (const exportType of ['html', 'txt', 'etherpad']) {
describe(`export to ${exportType}`, function () {
let text;
// This ought to be before(), but it must run after the top-level beforeEach() above.
beforeEach(async function () {
if (text != null) return;
let req = agent.get(`/p/${readOnlyId}/export/${exportType}`);
if (authn) req = req.auth('user', 'user-password');
const res = await req
.expect(200)
.buffer(true).parse(superagent.parse.text);
text = res.text;
});
it('export OK', async function () {
assert.match(text, /This is the/);
});
it('writable pad ID is not leaked', async function () {
assert(!text.includes(testPadId));
});
it('re-import to read-only pad ID gives 403 forbidden', async function () {
let req = agent.post(`/p/${readOnlyId}/import`)
.attach('file', Buffer.from(text), {
filename: `/test.${exportType}`,
contentType: 'text/plain',
});
if (authn) req = req.auth('user', 'user-password');
await req.expect(403);
});
it('re-import to read-write pad ID gives 200 OK', async function () {
// The new pad ID must differ from testPadId because Etherpad refuses to import
// .etherpad files on top of a pad that already has edits.
let req = agent.post(`/p/${testPadId}_import/import`)
.attach('file', Buffer.from(text), {
filename: `/test.${exportType}`,
contentType: 'text/plain',
});
if (authn) req = req.auth('user', 'user-password');
await req.expect(200);
});
});
}
});
}
});
describe('Import/Export tests requiring AbiWord/LibreOffice', function () { describe('Import/Export tests requiring AbiWord/LibreOffice', function () {
this.timeout(10000);
before(async function () { before(async function () {
if ((!settings.abiword || settings.abiword.indexOf('/') === -1) && if ((!settings.abiword || settings.abiword.indexOf('/') === -1) &&
(!settings.soffice || settings.soffice.indexOf('/') === -1)) { (!settings.soffice || settings.soffice.indexOf('/') === -1)) {

View file

@ -18,7 +18,6 @@ describe(__filename, function () {
describe('Connectivity for instance-level API tests', function () { describe('Connectivity for instance-level API tests', function () {
it('can connect', function (done) { it('can connect', function (done) {
this.timeout(150);
agent.get('/api/') agent.get('/api/')
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200, done); .expect(200, done);
@ -27,7 +26,6 @@ describe(__filename, function () {
describe('getStats', function () { describe('getStats', function () {
it('Gets the stats of a running instance', function (done) { it('Gets the stats of a running instance', function (done) {
this.timeout(100);
agent.get(endPoint('getStats')) agent.get(endPoint('getStats'))
.expect((res) => { .expect((res) => {
if (res.body.code !== 0) throw new Error('getStats() failed'); if (res.body.code !== 0) throw new Error('getStats() failed');

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,6 @@ describe(__filename, function () {
describe('API Versioning', function () { describe('API Versioning', function () {
it('errors if can not connect', async function () { it('errors if can not connect', async function () {
this.timeout(200);
await agent.get('/api/') await agent.get('/api/')
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
@ -60,7 +59,6 @@ describe(__filename, function () {
describe('API: Group creation and deletion', function () { describe('API: Group creation and deletion', function () {
it('createGroup', async function () { it('createGroup', async function () {
this.timeout(100);
await agent.get(endPoint('createGroup')) await agent.get(endPoint('createGroup'))
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -72,7 +70,6 @@ describe(__filename, function () {
}); });
it('listSessionsOfGroup for empty group', async function () { it('listSessionsOfGroup for empty group', async function () {
this.timeout(100);
await agent.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`) await agent.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -83,7 +80,6 @@ describe(__filename, function () {
}); });
it('deleteGroup', async function () { it('deleteGroup', async function () {
this.timeout(100);
await agent.get(`${endPoint('deleteGroup')}&groupID=${groupID}`) await agent.get(`${endPoint('deleteGroup')}&groupID=${groupID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -93,7 +89,6 @@ describe(__filename, function () {
}); });
it('createGroupIfNotExistsFor', async function () { it('createGroupIfNotExistsFor', async function () {
this.timeout(100);
await agent.get(`${endPoint('createGroupIfNotExistsFor')}&groupMapper=management`) await agent.get(`${endPoint('createGroupIfNotExistsFor')}&groupMapper=management`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -106,7 +101,6 @@ describe(__filename, function () {
// Test coverage for https://github.com/ether/etherpad-lite/issues/4227 // Test coverage for https://github.com/ether/etherpad-lite/issues/4227
// Creates a group, creates 2 sessions, 2 pads and then deletes the group. // Creates a group, creates 2 sessions, 2 pads and then deletes the group.
it('createGroup', async function () { it('createGroup', async function () {
this.timeout(100);
await agent.get(endPoint('createGroup')) await agent.get(endPoint('createGroup'))
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -118,7 +112,6 @@ describe(__filename, function () {
}); });
it('createAuthor', async function () { it('createAuthor', async function () {
this.timeout(100);
await agent.get(endPoint('createAuthor')) await agent.get(endPoint('createAuthor'))
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -130,7 +123,6 @@ describe(__filename, function () {
}); });
it('createSession', async function () { it('createSession', async function () {
this.timeout(100);
await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` + await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` +
'&validUntil=999999999999') '&validUntil=999999999999')
.expect(200) .expect(200)
@ -143,7 +135,6 @@ describe(__filename, function () {
}); });
it('createSession', async function () { it('createSession', async function () {
this.timeout(100);
await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` + await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` +
'&validUntil=999999999999') '&validUntil=999999999999')
.expect(200) .expect(200)
@ -156,7 +147,6 @@ describe(__filename, function () {
}); });
it('createGroupPad', async function () { it('createGroupPad', async function () {
this.timeout(100);
await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x1234567`) await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x1234567`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -166,7 +156,6 @@ describe(__filename, function () {
}); });
it('createGroupPad', async function () { it('createGroupPad', async function () {
this.timeout(100);
await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x12345678`) await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x12345678`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -176,7 +165,6 @@ describe(__filename, function () {
}); });
it('deleteGroup', async function () { it('deleteGroup', async function () {
this.timeout(100);
await agent.get(`${endPoint('deleteGroup')}&groupID=${groupID}`) await agent.get(`${endPoint('deleteGroup')}&groupID=${groupID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -189,7 +177,6 @@ describe(__filename, function () {
describe('API: Author creation', function () { describe('API: Author creation', function () {
it('createGroup', async function () { it('createGroup', async function () {
this.timeout(100);
await agent.get(endPoint('createGroup')) await agent.get(endPoint('createGroup'))
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -201,7 +188,6 @@ describe(__filename, function () {
}); });
it('createAuthor', async function () { it('createAuthor', async function () {
this.timeout(100);
await agent.get(endPoint('createAuthor')) await agent.get(endPoint('createAuthor'))
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -212,7 +198,6 @@ describe(__filename, function () {
}); });
it('createAuthor with name', async function () { it('createAuthor with name', async function () {
this.timeout(100);
await agent.get(`${endPoint('createAuthor')}&name=john`) await agent.get(`${endPoint('createAuthor')}&name=john`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -224,7 +209,6 @@ describe(__filename, function () {
}); });
it('createAuthorIfNotExistsFor', async function () { it('createAuthorIfNotExistsFor', async function () {
this.timeout(100);
await agent.get(`${endPoint('createAuthorIfNotExistsFor')}&authorMapper=chris`) await agent.get(`${endPoint('createAuthorIfNotExistsFor')}&authorMapper=chris`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -235,7 +219,6 @@ describe(__filename, function () {
}); });
it('getAuthorName', async function () { it('getAuthorName', async function () {
this.timeout(100);
await agent.get(`${endPoint('getAuthorName')}&authorID=${authorID}`) await agent.get(`${endPoint('getAuthorName')}&authorID=${authorID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -248,7 +231,6 @@ describe(__filename, function () {
describe('API: Sessions', function () { describe('API: Sessions', function () {
it('createSession', async function () { it('createSession', async function () {
this.timeout(100);
await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` + await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` +
'&validUntil=999999999999') '&validUntil=999999999999')
.expect(200) .expect(200)
@ -261,7 +243,6 @@ describe(__filename, function () {
}); });
it('getSessionInfo', async function () { it('getSessionInfo', async function () {
this.timeout(100);
await agent.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`) await agent.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -274,7 +255,6 @@ describe(__filename, function () {
}); });
it('listSessionsOfGroup', async function () { it('listSessionsOfGroup', async function () {
this.timeout(100);
await agent.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`) await agent.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -285,7 +265,6 @@ describe(__filename, function () {
}); });
it('deleteSession', async function () { it('deleteSession', async function () {
this.timeout(100);
await agent.get(`${endPoint('deleteSession')}&sessionID=${sessionID}`) await agent.get(`${endPoint('deleteSession')}&sessionID=${sessionID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -295,7 +274,6 @@ describe(__filename, function () {
}); });
it('getSessionInfo of deleted session', async function () { it('getSessionInfo of deleted session', async function () {
this.timeout(100);
await agent.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`) await agent.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -307,7 +285,6 @@ describe(__filename, function () {
describe('API: Group pad management', function () { describe('API: Group pad management', function () {
it('listPads', async function () { it('listPads', async function () {
this.timeout(100);
await agent.get(`${endPoint('listPads')}&groupID=${groupID}`) await agent.get(`${endPoint('listPads')}&groupID=${groupID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -318,7 +295,6 @@ describe(__filename, function () {
}); });
it('createGroupPad', async function () { it('createGroupPad', async function () {
this.timeout(100);
await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=${padID}`) await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=${padID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -329,7 +305,6 @@ describe(__filename, function () {
}); });
it('listPads after creating a group pad', async function () { it('listPads after creating a group pad', async function () {
this.timeout(100);
await agent.get(`${endPoint('listPads')}&groupID=${groupID}`) await agent.get(`${endPoint('listPads')}&groupID=${groupID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -342,7 +317,6 @@ describe(__filename, function () {
describe('API: Pad security', function () { describe('API: Pad security', function () {
it('getPublicStatus', async function () { it('getPublicStatus', async function () {
this.timeout(100);
await agent.get(`${endPoint('getPublicStatus')}&padID=${padID}`) await agent.get(`${endPoint('getPublicStatus')}&padID=${padID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -353,7 +327,6 @@ describe(__filename, function () {
}); });
it('setPublicStatus', async function () { it('setPublicStatus', async function () {
this.timeout(100);
await agent.get(`${endPoint('setPublicStatus')}&padID=${padID}&publicStatus=true`) await agent.get(`${endPoint('setPublicStatus')}&padID=${padID}&publicStatus=true`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -363,7 +336,6 @@ describe(__filename, function () {
}); });
it('getPublicStatus after changing public status', async function () { it('getPublicStatus after changing public status', async function () {
this.timeout(100);
await agent.get(`${endPoint('getPublicStatus')}&padID=${padID}`) await agent.get(`${endPoint('getPublicStatus')}&padID=${padID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -380,7 +352,6 @@ describe(__filename, function () {
describe('API: Misc', function () { describe('API: Misc', function () {
it('listPadsOfAuthor', async function () { it('listPadsOfAuthor', async function () {
this.timeout(100);
await agent.get(`${endPoint('listPadsOfAuthor')}&authorID=${authorID}`) await agent.get(`${endPoint('listPadsOfAuthor')}&authorID=${authorID}`)
.expect(200) .expect(200)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)

View file

@ -0,0 +1,160 @@
'use strict';
const ChatMessage = require('../../../static/js/ChatMessage');
const {Pad} = require('../../../node/db/Pad');
const assert = require('assert').strict;
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs');
const logger = common.logger;
const checkHook = async (hookName, checkFn) => {
if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = [];
await new Promise((resolve, reject) => {
pluginDefs.hooks[hookName].push({
hook_fn: async (hookName, context) => {
if (checkFn == null) return;
logger.debug(`hook ${hookName} invoked`);
try {
// Make sure checkFn is called only once.
const _checkFn = checkFn;
checkFn = null;
await _checkFn(context);
} catch (err) {
reject(err);
return;
}
resolve();
},
});
});
};
const sendMessage = (socket, data) => {
socket.send({
type: 'COLLABROOM',
component: 'pad',
data,
});
};
const sendChat = (socket, message) => sendMessage(socket, {type: 'CHAT_MESSAGE', message});
describe(__filename, function () {
const padId = 'testChatPad';
const hooksBackup = {};
before(async function () {
for (const [name, defs] of Object.entries(pluginDefs.hooks)) {
if (defs == null) continue;
hooksBackup[name] = defs;
}
});
beforeEach(async function () {
for (const [name, defs] of Object.entries(hooksBackup)) pluginDefs.hooks[name] = [...defs];
for (const name of Object.keys(pluginDefs.hooks)) {
if (hooksBackup[name] == null) delete pluginDefs.hooks[name];
}
if (await padManager.doesPadExist(padId)) {
const pad = await padManager.getPad(padId);
await pad.remove();
}
});
after(async function () {
Object.assign(pluginDefs.hooks, hooksBackup);
for (const name of Object.keys(pluginDefs.hooks)) {
if (hooksBackup[name] == null) delete pluginDefs.hooks[name];
}
});
describe('chatNewMessage hook', function () {
let authorId;
let socket;
beforeEach(async function () {
socket = await common.connect();
const {data: clientVars} = await common.handshake(socket, padId);
authorId = clientVars.userId;
});
afterEach(async function () {
socket.close();
});
it('message', async function () {
const start = Date.now();
await Promise.all([
checkHook('chatNewMessage', ({message}) => {
assert(message != null);
assert(message instanceof ChatMessage);
assert.equal(message.authorId, authorId);
assert.equal(message.text, this.test.title);
assert(message.time >= start);
assert(message.time <= Date.now());
}),
sendChat(socket, {text: this.test.title}),
]);
});
it('pad', async function () {
await Promise.all([
checkHook('chatNewMessage', ({pad}) => {
assert(pad != null);
assert(pad instanceof Pad);
assert.equal(pad.id, padId);
}),
sendChat(socket, {text: this.test.title}),
]);
});
it('padId', async function () {
await Promise.all([
checkHook('chatNewMessage', (context) => {
assert.equal(context.padId, padId);
}),
sendChat(socket, {text: this.test.title}),
]);
});
it('mutations propagate', async function () {
const listen = async (type) => await new Promise((resolve) => {
const handler = (msg) => {
if (msg.type !== 'COLLABROOM') return;
if (msg.data == null || msg.data.type !== type) return;
resolve(msg.data);
socket.off('message', handler);
};
socket.on('message', handler);
});
const modifiedText = `${this.test.title} <added changes>`;
const customMetadata = {foo: this.test.title};
await Promise.all([
checkHook('chatNewMessage', ({message}) => {
message.text = modifiedText;
message.customMetadata = customMetadata;
}),
(async () => {
const {message} = await listen('CHAT_MESSAGE');
assert(message != null);
assert.equal(message.text, modifiedText);
assert.deepEqual(message.customMetadata, customMetadata);
})(),
sendChat(socket, {text: this.test.title}),
]);
// Simulate fetch of historical chat messages when a pad is first loaded.
await Promise.all([
(async () => {
const {messages: [message]} = await listen('CHAT_MESSAGES');
assert(message != null);
assert.equal(message.text, modifiedText);
assert.deepEqual(message.customMetadata, customMetadata);
})(),
sendMessage(socket, {type: 'GET_CHAT_MESSAGES', start: 0, end: 0}),
]);
});
});
});

View file

@ -11,8 +11,8 @@
const AttributePool = require('../../../static/js/AttributePool'); const AttributePool = require('../../../static/js/AttributePool');
const assert = require('assert').strict; const assert = require('assert').strict;
const cheerio = require('cheerio');
const contentcollector = require('../../../static/js/contentcollector'); const contentcollector = require('../../../static/js/contentcollector');
const jsdom = require('jsdom');
const tests = { const tests = {
nestedLi: { nestedLi: {
@ -285,15 +285,13 @@ describe(__filename, function () {
} }
it(testObj.description, async function () { it(testObj.description, async function () {
this.timeout(250); const {window: {document}} = new jsdom.JSDOM(testObj.html);
const $ = cheerio.load(testObj.html); // Load HTML into Cheerio
const doc = $('body')[0]; // Creates a dom-like representation of HTML
// Create an empty attribute pool // Create an empty attribute pool
const apool = new AttributePool(); const apool = new AttributePool();
// 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, apool); const cc = contentcollector.makeContentCollector(true, null, apool);
cc.collectContent(doc); cc.collectContent(document.body);
const result = cc.finish(); const result = cc.finish();
const gotAttributes = result.lineAttribs; const gotAttributes = result.lineAttribs;
const wantAttributes = testObj.wantLineAttribs; const wantAttributes = testObj.wantLineAttribs;

View file

@ -93,13 +93,11 @@ describe(__filename, function () {
describe('basic behavior', function () { describe('basic behavior', function () {
it('passes hook name', async function () { it('passes hook name', async function () {
this.timeout(30);
hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; hook.hook_fn = (hn) => { assert.equal(hn, hookName); };
callHookFnSync(hook); callHookFnSync(hook);
}); });
it('passes context', async function () { it('passes context', async function () {
this.timeout(30);
for (const val of ['value', null, undefined]) { for (const val of ['value', null, undefined]) {
hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); }; hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); };
callHookFnSync(hook, val); callHookFnSync(hook, val);
@ -107,7 +105,6 @@ describe(__filename, function () {
}); });
it('returns the value provided to the callback', async function () { it('returns the value provided to the callback', async function () {
this.timeout(30);
for (const val of ['value', null, undefined]) { for (const val of ['value', null, undefined]) {
hook.hook_fn = (hn, ctx, cb) => { cb(ctx); }; hook.hook_fn = (hn, ctx, cb) => { cb(ctx); };
assert.equal(callHookFnSync(hook, val), val); assert.equal(callHookFnSync(hook, val), val);
@ -115,7 +112,6 @@ describe(__filename, function () {
}); });
it('returns the value returned by the hook function', async function () { it('returns the value returned by the hook function', async function () {
this.timeout(30);
for (const val of ['value', null, undefined]) { for (const val of ['value', null, undefined]) {
// Must not have the cb parameter otherwise returning undefined will error. // Must not have the cb parameter otherwise returning undefined will error.
hook.hook_fn = (hn, ctx) => ctx; hook.hook_fn = (hn, ctx) => ctx;
@ -124,19 +120,16 @@ describe(__filename, function () {
}); });
it('does not catch exceptions', async function () { it('does not catch exceptions', async function () {
this.timeout(30);
hook.hook_fn = () => { throw new Error('test exception'); }; hook.hook_fn = () => { throw new Error('test exception'); };
assert.throws(() => callHookFnSync(hook), {message: 'test exception'}); assert.throws(() => callHookFnSync(hook), {message: 'test exception'});
}); });
it('callback returns undefined', async function () { it('callback returns undefined', async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); }; hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); };
callHookFnSync(hook); callHookFnSync(hook);
}); });
it('checks for deprecation', async function () { it('checks for deprecation', async function () {
this.timeout(30);
sinon.stub(console, 'warn'); sinon.stub(console, 'warn');
hooks.deprecationNotices[hookName] = 'test deprecation'; hooks.deprecationNotices[hookName] = 'test deprecation';
callHookFnSync(hook); callHookFnSync(hook);
@ -149,7 +142,6 @@ describe(__filename, function () {
describe('supported hook function styles', function () { describe('supported hook function styles', function () {
for (const tc of supportedSyncHookFunctions) { for (const tc of supportedSyncHookFunctions) {
it(tc.name, async function () { it(tc.name, async function () {
this.timeout(30);
sinon.stub(console, 'warn'); sinon.stub(console, 'warn');
sinon.stub(console, 'error'); sinon.stub(console, 'error');
hook.hook_fn = tc.fn; hook.hook_fn = tc.fn;
@ -194,7 +186,6 @@ describe(__filename, function () {
for (const tc of testCases) { for (const tc of testCases) {
it(tc.name, async function () { it(tc.name, async function () {
this.timeout(30);
sinon.stub(console, 'error'); sinon.stub(console, 'error');
hook.hook_fn = tc.fn; hook.hook_fn = tc.fn;
assert.equal(callHookFnSync(hook), tc.wantVal); assert.equal(callHookFnSync(hook), tc.wantVal);
@ -246,7 +237,6 @@ describe(__filename, function () {
if (step1.async && step2.async) continue; if (step1.async && step2.async) continue;
it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx, cb) => { hook.hook_fn = (hn, ctx, cb) => {
step1.fn(cb, new Error(ctx.ret1), ctx.ret1); step1.fn(cb, new Error(ctx.ret1), ctx.ret1);
return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); return step2.fn(cb, new Error(ctx.ret2), ctx.ret2);
@ -310,7 +300,6 @@ describe(__filename, function () {
if (step1.rejects !== step2.rejects) continue; if (step1.rejects !== step2.rejects) continue;
it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () {
this.timeout(30);
const err = new Error('val'); const err = new Error('val');
hook.hook_fn = (hn, ctx, cb) => { hook.hook_fn = (hn, ctx, cb) => {
step1.fn(cb, err, 'val'); step1.fn(cb, err, 'val');
@ -336,32 +325,27 @@ describe(__filename, function () {
describe('hooks.callAll', function () { describe('hooks.callAll', function () {
describe('basic behavior', function () { describe('basic behavior', function () {
it('calls all in order', async function () { it('calls all in order', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(1), makeHook(2), makeHook(3)); testHooks.push(makeHook(1), makeHook(2), makeHook(3));
assert.deepEqual(hooks.callAll(hookName), [1, 2, 3]); assert.deepEqual(hooks.callAll(hookName), [1, 2, 3]);
}); });
it('passes hook name', async function () { it('passes hook name', async function () {
this.timeout(30);
hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; hook.hook_fn = (hn) => { assert.equal(hn, hookName); };
hooks.callAll(hookName); hooks.callAll(hookName);
}); });
it('undefined context -> {}', async function () { it('undefined context -> {}', async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); };
hooks.callAll(hookName); hooks.callAll(hookName);
}); });
it('null context -> {}', async function () { it('null context -> {}', async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); };
hooks.callAll(hookName, null); hooks.callAll(hookName, null);
}); });
it('context unmodified', async function () { it('context unmodified', async function () {
this.timeout(30);
const wantContext = {}; const wantContext = {};
hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); };
hooks.callAll(hookName, wantContext); hooks.callAll(hookName, wantContext);
@ -370,40 +354,34 @@ describe(__filename, function () {
describe('result processing', function () { describe('result processing', function () {
it('no registered hooks (undefined) -> []', async function () { it('no registered hooks (undefined) -> []', async function () {
this.timeout(30);
delete plugins.hooks.testHook; delete plugins.hooks.testHook;
assert.deepEqual(hooks.callAll(hookName), []); assert.deepEqual(hooks.callAll(hookName), []);
}); });
it('no registered hooks (empty list) -> []', async function () { it('no registered hooks (empty list) -> []', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
assert.deepEqual(hooks.callAll(hookName), []); assert.deepEqual(hooks.callAll(hookName), []);
}); });
it('flattens one level', async function () { it('flattens one level', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]]));
assert.deepEqual(hooks.callAll(hookName), [1, 2, [3]]); assert.deepEqual(hooks.callAll(hookName), [1, 2, [3]]);
}); });
it('filters out undefined', async function () { it('filters out undefined', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(), makeHook([2]), makeHook([[3]])); testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]));
assert.deepEqual(hooks.callAll(hookName), [2, [3]]); assert.deepEqual(hooks.callAll(hookName), [2, [3]]);
}); });
it('preserves null', async function () { it('preserves null', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(null), makeHook([2]), makeHook([[3]])); testHooks.push(makeHook(null), makeHook([2]), makeHook([[3]]));
assert.deepEqual(hooks.callAll(hookName), [null, 2, [3]]); assert.deepEqual(hooks.callAll(hookName), [null, 2, [3]]);
}); });
it('all undefined -> []', async function () { it('all undefined -> []', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(), makeHook()); testHooks.push(makeHook(), makeHook());
assert.deepEqual(hooks.callAll(hookName), []); assert.deepEqual(hooks.callAll(hookName), []);
@ -413,44 +391,37 @@ describe(__filename, function () {
describe('hooks.callFirst', function () { describe('hooks.callFirst', function () {
it('no registered hooks (undefined) -> []', async function () { it('no registered hooks (undefined) -> []', async function () {
this.timeout(30);
delete plugins.hooks.testHook; delete plugins.hooks.testHook;
assert.deepEqual(hooks.callFirst(hookName), []); assert.deepEqual(hooks.callFirst(hookName), []);
}); });
it('no registered hooks (empty list) -> []', async function () { it('no registered hooks (empty list) -> []', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
assert.deepEqual(hooks.callFirst(hookName), []); assert.deepEqual(hooks.callFirst(hookName), []);
}); });
it('passes hook name => {}', async function () { it('passes hook name => {}', async function () {
this.timeout(30);
hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; hook.hook_fn = (hn) => { assert.equal(hn, hookName); };
hooks.callFirst(hookName); hooks.callFirst(hookName);
}); });
it('undefined context => {}', async function () { it('undefined context => {}', async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); };
hooks.callFirst(hookName); hooks.callFirst(hookName);
}); });
it('null context => {}', async function () { it('null context => {}', async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); };
hooks.callFirst(hookName, null); hooks.callFirst(hookName, null);
}); });
it('context unmodified', async function () { it('context unmodified', async function () {
this.timeout(30);
const wantContext = {}; const wantContext = {};
hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); };
hooks.callFirst(hookName, wantContext); hooks.callFirst(hookName, wantContext);
}); });
it('predicate never satisfied -> calls all in order', async function () { it('predicate never satisfied -> calls all in order', async function () {
this.timeout(30);
const gotCalls = []; const gotCalls = [];
testHooks.length = 0; testHooks.length = 0;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
@ -463,35 +434,30 @@ describe(__filename, function () {
}); });
it('stops when predicate is satisfied', async function () { it('stops when predicate is satisfied', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); testHooks.push(makeHook(), makeHook('val1'), makeHook('val2'));
assert.deepEqual(hooks.callFirst(hookName), ['val1']); assert.deepEqual(hooks.callFirst(hookName), ['val1']);
}); });
it('skips values that do not satisfy predicate (undefined)', async function () { it('skips values that do not satisfy predicate (undefined)', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(), makeHook('val1')); testHooks.push(makeHook(), makeHook('val1'));
assert.deepEqual(hooks.callFirst(hookName), ['val1']); assert.deepEqual(hooks.callFirst(hookName), ['val1']);
}); });
it('skips values that do not satisfy predicate (empty list)', async function () { it('skips values that do not satisfy predicate (empty list)', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook([]), makeHook('val1')); testHooks.push(makeHook([]), makeHook('val1'));
assert.deepEqual(hooks.callFirst(hookName), ['val1']); assert.deepEqual(hooks.callFirst(hookName), ['val1']);
}); });
it('null satisifes the predicate', async function () { it('null satisifes the predicate', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(null), makeHook('val1')); testHooks.push(makeHook(null), makeHook('val1'));
assert.deepEqual(hooks.callFirst(hookName), [null]); assert.deepEqual(hooks.callFirst(hookName), [null]);
}); });
it('non-empty arrays are returned unmodified', async function () { it('non-empty arrays are returned unmodified', async function () {
this.timeout(30);
const want = ['val1']; const want = ['val1'];
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(want), makeHook(['val2'])); testHooks.push(makeHook(want), makeHook(['val2']));
@ -499,7 +465,6 @@ describe(__filename, function () {
}); });
it('value can be passed via callback', async function () { it('value can be passed via callback', async function () {
this.timeout(30);
const want = {}; const want = {};
hook.hook_fn = (hn, ctx, cb) => { cb(want); }; hook.hook_fn = (hn, ctx, cb) => { cb(want); };
const got = hooks.callFirst(hookName); const got = hooks.callFirst(hookName);
@ -513,13 +478,11 @@ describe(__filename, function () {
describe('basic behavior', function () { describe('basic behavior', function () {
it('passes hook name', async function () { it('passes hook name', async function () {
this.timeout(30);
hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; hook.hook_fn = (hn) => { assert.equal(hn, hookName); };
await callHookFnAsync(hook); await callHookFnAsync(hook);
}); });
it('passes context', async function () { it('passes context', async function () {
this.timeout(30);
for (const val of ['value', null, undefined]) { for (const val of ['value', null, undefined]) {
hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); }; hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); };
await callHookFnAsync(hook, val); await callHookFnAsync(hook, val);
@ -527,7 +490,6 @@ describe(__filename, function () {
}); });
it('returns the value provided to the callback', async function () { it('returns the value provided to the callback', async function () {
this.timeout(30);
for (const val of ['value', null, undefined]) { for (const val of ['value', null, undefined]) {
hook.hook_fn = (hn, ctx, cb) => { cb(ctx); }; hook.hook_fn = (hn, ctx, cb) => { cb(ctx); };
assert.equal(await callHookFnAsync(hook, val), val); assert.equal(await callHookFnAsync(hook, val), val);
@ -536,7 +498,6 @@ describe(__filename, function () {
}); });
it('returns the value returned by the hook function', async function () { it('returns the value returned by the hook function', async function () {
this.timeout(30);
for (const val of ['value', null, undefined]) { for (const val of ['value', null, undefined]) {
// Must not have the cb parameter otherwise returning undefined will never resolve. // Must not have the cb parameter otherwise returning undefined will never resolve.
hook.hook_fn = (hn, ctx) => ctx; hook.hook_fn = (hn, ctx) => ctx;
@ -546,31 +507,26 @@ describe(__filename, function () {
}); });
it('rejects if it throws an exception', async function () { it('rejects if it throws an exception', async function () {
this.timeout(30);
hook.hook_fn = () => { throw new Error('test exception'); }; hook.hook_fn = () => { throw new Error('test exception'); };
await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); await assert.rejects(callHookFnAsync(hook), {message: 'test exception'});
}); });
it('rejects if rejected Promise passed to callback', async function () { it('rejects if rejected Promise passed to callback', async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx, cb) => cb(Promise.reject(new Error('test exception'))); hook.hook_fn = (hn, ctx, cb) => cb(Promise.reject(new Error('test exception')));
await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); await assert.rejects(callHookFnAsync(hook), {message: 'test exception'});
}); });
it('rejects if rejected Promise returned', async function () { it('rejects if rejected Promise returned', async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx, cb) => Promise.reject(new Error('test exception')); hook.hook_fn = (hn, ctx, cb) => Promise.reject(new Error('test exception'));
await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); await assert.rejects(callHookFnAsync(hook), {message: 'test exception'});
}); });
it('callback returns undefined', async function () { it('callback returns undefined', async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); }; hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); };
await callHookFnAsync(hook); await callHookFnAsync(hook);
}); });
it('checks for deprecation', async function () { it('checks for deprecation', async function () {
this.timeout(30);
sinon.stub(console, 'warn'); sinon.stub(console, 'warn');
hooks.deprecationNotices[hookName] = 'test deprecation'; hooks.deprecationNotices[hookName] = 'test deprecation';
await callHookFnAsync(hook); await callHookFnAsync(hook);
@ -663,7 +619,6 @@ describe(__filename, function () {
for (const tc of supportedSyncHookFunctions.concat(supportedHookFunctions)) { for (const tc of supportedSyncHookFunctions.concat(supportedHookFunctions)) {
it(tc.name, async function () { it(tc.name, async function () {
this.timeout(30);
sinon.stub(console, 'warn'); sinon.stub(console, 'warn');
sinon.stub(console, 'error'); sinon.stub(console, 'error');
hook.hook_fn = tc.fn; hook.hook_fn = tc.fn;
@ -811,7 +766,6 @@ describe(__filename, function () {
if (step1.name.startsWith('return ') || step1.name === 'throw') continue; if (step1.name.startsWith('return ') || step1.name === 'throw') continue;
for (const step2 of behaviors) { for (const step2 of behaviors) {
it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx, cb) => { hook.hook_fn = (hn, ctx, cb) => {
step1.fn(cb, new Error(ctx.ret1), ctx.ret1); step1.fn(cb, new Error(ctx.ret1), ctx.ret1);
return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); return step2.fn(cb, new Error(ctx.ret2), ctx.ret2);
@ -865,7 +819,6 @@ describe(__filename, function () {
if (step1.rejects !== step2.rejects) continue; if (step1.rejects !== step2.rejects) continue;
it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () {
this.timeout(30);
const err = new Error('val'); const err = new Error('val');
hook.hook_fn = (hn, ctx, cb) => { hook.hook_fn = (hn, ctx, cb) => {
step1.fn(cb, err, 'val'); step1.fn(cb, err, 'val');
@ -891,7 +844,6 @@ describe(__filename, function () {
describe('hooks.aCallAll', function () { describe('hooks.aCallAll', function () {
describe('basic behavior', function () { describe('basic behavior', function () {
it('calls all asynchronously, returns values in order', async function () { it('calls all asynchronously, returns values in order', async function () {
this.timeout(30);
testHooks.length = 0; // Delete the boilerplate hook -- this test doesn't use it. testHooks.length = 0; // Delete the boilerplate hook -- this test doesn't use it.
let nextIndex = 0; let nextIndex = 0;
const hookPromises = []; const hookPromises = [];
@ -926,25 +878,21 @@ describe(__filename, function () {
}); });
it('passes hook name', async function () { it('passes hook name', async function () {
this.timeout(30);
hook.hook_fn = async (hn) => { assert.equal(hn, hookName); }; hook.hook_fn = async (hn) => { assert.equal(hn, hookName); };
await hooks.aCallAll(hookName); await hooks.aCallAll(hookName);
}); });
it('undefined context -> {}', async function () { it('undefined context -> {}', async function () {
this.timeout(30);
hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); };
await hooks.aCallAll(hookName); await hooks.aCallAll(hookName);
}); });
it('null context -> {}', async function () { it('null context -> {}', async function () {
this.timeout(30);
hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); };
await hooks.aCallAll(hookName, null); await hooks.aCallAll(hookName, null);
}); });
it('context unmodified', async function () { it('context unmodified', async function () {
this.timeout(30);
const wantContext = {}; const wantContext = {};
hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); }; hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); };
await hooks.aCallAll(hookName, wantContext); await hooks.aCallAll(hookName, wantContext);
@ -953,13 +901,11 @@ describe(__filename, function () {
describe('aCallAll callback', function () { describe('aCallAll callback', function () {
it('exception in callback rejects', async function () { it('exception in callback rejects', async function () {
this.timeout(30);
const p = hooks.aCallAll(hookName, {}, () => { throw new Error('test exception'); }); const p = hooks.aCallAll(hookName, {}, () => { throw new Error('test exception'); });
await assert.rejects(p, {message: 'test exception'}); await assert.rejects(p, {message: 'test exception'});
}); });
it('propagates error on exception', async function () { it('propagates error on exception', async function () {
this.timeout(30);
hook.hook_fn = () => { throw new Error('test exception'); }; hook.hook_fn = () => { throw new Error('test exception'); };
await hooks.aCallAll(hookName, {}, (err) => { await hooks.aCallAll(hookName, {}, (err) => {
assert(err instanceof Error); assert(err instanceof Error);
@ -968,14 +914,12 @@ describe(__filename, function () {
}); });
it('propagages null error on success', async function () { it('propagages null error on success', async function () {
this.timeout(30);
await hooks.aCallAll(hookName, {}, (err) => { await hooks.aCallAll(hookName, {}, (err) => {
assert(err == null, `got non-null error: ${err}`); assert(err == null, `got non-null error: ${err}`);
}); });
}); });
it('propagages results on success', async function () { it('propagages results on success', async function () {
this.timeout(30);
hook.hook_fn = () => 'val'; hook.hook_fn = () => 'val';
await hooks.aCallAll(hookName, {}, (err, results) => { await hooks.aCallAll(hookName, {}, (err, results) => {
assert.deepEqual(results, ['val']); assert.deepEqual(results, ['val']);
@ -983,47 +927,40 @@ describe(__filename, function () {
}); });
it('returns callback return value', async function () { it('returns callback return value', async function () {
this.timeout(30);
assert.equal(await hooks.aCallAll(hookName, {}, () => 'val'), 'val'); assert.equal(await hooks.aCallAll(hookName, {}, () => 'val'), 'val');
}); });
}); });
describe('result processing', function () { describe('result processing', function () {
it('no registered hooks (undefined) -> []', async function () { it('no registered hooks (undefined) -> []', async function () {
this.timeout(30);
delete plugins.hooks[hookName]; delete plugins.hooks[hookName];
assert.deepEqual(await hooks.aCallAll(hookName), []); assert.deepEqual(await hooks.aCallAll(hookName), []);
}); });
it('no registered hooks (empty list) -> []', async function () { it('no registered hooks (empty list) -> []', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
assert.deepEqual(await hooks.aCallAll(hookName), []); assert.deepEqual(await hooks.aCallAll(hookName), []);
}); });
it('flattens one level', async function () { it('flattens one level', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]]));
assert.deepEqual(await hooks.aCallAll(hookName), [1, 2, [3]]); assert.deepEqual(await hooks.aCallAll(hookName), [1, 2, [3]]);
}); });
it('filters out undefined', async function () { it('filters out undefined', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve())); testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve()));
assert.deepEqual(await hooks.aCallAll(hookName), [2, [3]]); assert.deepEqual(await hooks.aCallAll(hookName), [2, [3]]);
}); });
it('preserves null', async function () { it('preserves null', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null))); testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null)));
assert.deepEqual(await hooks.aCallAll(hookName), [null, 2, null]); assert.deepEqual(await hooks.aCallAll(hookName), [null, 2, null]);
}); });
it('all undefined -> []', async function () { it('all undefined -> []', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(), makeHook(Promise.resolve())); testHooks.push(makeHook(), makeHook(Promise.resolve()));
assert.deepEqual(await hooks.aCallAll(hookName), []); assert.deepEqual(await hooks.aCallAll(hookName), []);
@ -1034,7 +971,6 @@ describe(__filename, function () {
describe('hooks.callAllSerial', function () { describe('hooks.callAllSerial', function () {
describe('basic behavior', function () { describe('basic behavior', function () {
it('calls all asynchronously, serially, in order', async function () { it('calls all asynchronously, serially, in order', async function () {
this.timeout(30);
const gotCalls = []; const gotCalls = [];
testHooks.length = 0; testHooks.length = 0;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
@ -1057,25 +993,21 @@ describe(__filename, function () {
}); });
it('passes hook name', async function () { it('passes hook name', async function () {
this.timeout(30);
hook.hook_fn = async (hn) => { assert.equal(hn, hookName); }; hook.hook_fn = async (hn) => { assert.equal(hn, hookName); };
await hooks.callAllSerial(hookName); await hooks.callAllSerial(hookName);
}); });
it('undefined context -> {}', async function () { it('undefined context -> {}', async function () {
this.timeout(30);
hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); };
await hooks.callAllSerial(hookName); await hooks.callAllSerial(hookName);
}); });
it('null context -> {}', async function () { it('null context -> {}', async function () {
this.timeout(30);
hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); };
await hooks.callAllSerial(hookName, null); await hooks.callAllSerial(hookName, null);
}); });
it('context unmodified', async function () { it('context unmodified', async function () {
this.timeout(30);
const wantContext = {}; const wantContext = {};
hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); }; hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); };
await hooks.callAllSerial(hookName, wantContext); await hooks.callAllSerial(hookName, wantContext);
@ -1084,40 +1016,34 @@ describe(__filename, function () {
describe('result processing', function () { describe('result processing', function () {
it('no registered hooks (undefined) -> []', async function () { it('no registered hooks (undefined) -> []', async function () {
this.timeout(30);
delete plugins.hooks[hookName]; delete plugins.hooks[hookName];
assert.deepEqual(await hooks.callAllSerial(hookName), []); assert.deepEqual(await hooks.callAllSerial(hookName), []);
}); });
it('no registered hooks (empty list) -> []', async function () { it('no registered hooks (empty list) -> []', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
assert.deepEqual(await hooks.callAllSerial(hookName), []); assert.deepEqual(await hooks.callAllSerial(hookName), []);
}); });
it('flattens one level', async function () { it('flattens one level', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]]));
assert.deepEqual(await hooks.callAllSerial(hookName), [1, 2, [3]]); assert.deepEqual(await hooks.callAllSerial(hookName), [1, 2, [3]]);
}); });
it('filters out undefined', async function () { it('filters out undefined', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve())); testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve()));
assert.deepEqual(await hooks.callAllSerial(hookName), [2, [3]]); assert.deepEqual(await hooks.callAllSerial(hookName), [2, [3]]);
}); });
it('preserves null', async function () { it('preserves null', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null))); testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null)));
assert.deepEqual(await hooks.callAllSerial(hookName), [null, 2, null]); assert.deepEqual(await hooks.callAllSerial(hookName), [null, 2, null]);
}); });
it('all undefined -> []', async function () { it('all undefined -> []', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(), makeHook(Promise.resolve())); testHooks.push(makeHook(), makeHook(Promise.resolve()));
assert.deepEqual(await hooks.callAllSerial(hookName), []); assert.deepEqual(await hooks.callAllSerial(hookName), []);
@ -1127,44 +1053,37 @@ describe(__filename, function () {
describe('hooks.aCallFirst', function () { describe('hooks.aCallFirst', function () {
it('no registered hooks (undefined) -> []', async function () { it('no registered hooks (undefined) -> []', async function () {
this.timeout(30);
delete plugins.hooks.testHook; delete plugins.hooks.testHook;
assert.deepEqual(await hooks.aCallFirst(hookName), []); assert.deepEqual(await hooks.aCallFirst(hookName), []);
}); });
it('no registered hooks (empty list) -> []', async function () { it('no registered hooks (empty list) -> []', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
assert.deepEqual(await hooks.aCallFirst(hookName), []); assert.deepEqual(await hooks.aCallFirst(hookName), []);
}); });
it('passes hook name => {}', async function () { it('passes hook name => {}', async function () {
this.timeout(30);
hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; hook.hook_fn = (hn) => { assert.equal(hn, hookName); };
await hooks.aCallFirst(hookName); await hooks.aCallFirst(hookName);
}); });
it('undefined context => {}', async function () { it('undefined context => {}', async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); };
await hooks.aCallFirst(hookName); await hooks.aCallFirst(hookName);
}); });
it('null context => {}', async function () { it('null context => {}', async function () {
this.timeout(30);
hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); };
await hooks.aCallFirst(hookName, null); await hooks.aCallFirst(hookName, null);
}); });
it('context unmodified', async function () { it('context unmodified', async function () {
this.timeout(30);
const wantContext = {}; const wantContext = {};
hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); };
await hooks.aCallFirst(hookName, wantContext); await hooks.aCallFirst(hookName, wantContext);
}); });
it('default predicate: predicate never satisfied -> calls all in order', async function () { it('default predicate: predicate never satisfied -> calls all in order', async function () {
this.timeout(30);
const gotCalls = []; const gotCalls = [];
testHooks.length = 0; testHooks.length = 0;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
@ -1177,7 +1096,6 @@ describe(__filename, function () {
}); });
it('calls hook functions serially', async function () { it('calls hook functions serially', async function () {
this.timeout(30);
const gotCalls = []; const gotCalls = [];
testHooks.length = 0; testHooks.length = 0;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
@ -1200,35 +1118,30 @@ describe(__filename, function () {
}); });
it('default predicate: stops when satisfied', async function () { it('default predicate: stops when satisfied', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); testHooks.push(makeHook(), makeHook('val1'), makeHook('val2'));
assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']);
}); });
it('default predicate: skips values that do not satisfy (undefined)', async function () { it('default predicate: skips values that do not satisfy (undefined)', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(), makeHook('val1')); testHooks.push(makeHook(), makeHook('val1'));
assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']);
}); });
it('default predicate: skips values that do not satisfy (empty list)', async function () { it('default predicate: skips values that do not satisfy (empty list)', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook([]), makeHook('val1')); testHooks.push(makeHook([]), makeHook('val1'));
assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']);
}); });
it('default predicate: null satisifes', async function () { it('default predicate: null satisifes', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(null), makeHook('val1')); testHooks.push(makeHook(null), makeHook('val1'));
assert.deepEqual(await hooks.aCallFirst(hookName), [null]); assert.deepEqual(await hooks.aCallFirst(hookName), [null]);
}); });
it('custom predicate: called for each hook function', async function () { it('custom predicate: called for each hook function', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(0), makeHook(1), makeHook(2)); testHooks.push(makeHook(0), makeHook(1), makeHook(2));
let got = 0; let got = 0;
@ -1237,7 +1150,6 @@ describe(__filename, function () {
}); });
it('custom predicate: boolean false/true continues/stops iteration', async function () { it('custom predicate: boolean false/true continues/stops iteration', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(1), makeHook(2), makeHook(3)); testHooks.push(makeHook(1), makeHook(2), makeHook(3));
let nCall = 0; let nCall = 0;
@ -1250,7 +1162,6 @@ describe(__filename, function () {
}); });
it('custom predicate: non-boolean falsy/truthy continues/stops iteration', async function () { it('custom predicate: non-boolean falsy/truthy continues/stops iteration', async function () {
this.timeout(30);
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(1), makeHook(2), makeHook(3)); testHooks.push(makeHook(1), makeHook(2), makeHook(3));
let nCall = 0; let nCall = 0;
@ -1263,7 +1174,6 @@ describe(__filename, function () {
}); });
it('custom predicate: array value passed unmodified to predicate', async function () { it('custom predicate: array value passed unmodified to predicate', async function () {
this.timeout(30);
const want = [0]; const want = [0];
hook.hook_fn = () => want; hook.hook_fn = () => want;
const predicate = (got) => { assert.equal(got, want); }; // Note: *NOT* deepEqual! const predicate = (got) => { assert.equal(got, want); }; // Note: *NOT* deepEqual!
@ -1271,20 +1181,17 @@ describe(__filename, function () {
}); });
it('custom predicate: normalized value passed to predicate (undefined)', async function () { it('custom predicate: normalized value passed to predicate (undefined)', async function () {
this.timeout(30);
const predicate = (got) => { assert.deepEqual(got, []); }; const predicate = (got) => { assert.deepEqual(got, []); };
await hooks.aCallFirst(hookName, null, null, predicate); await hooks.aCallFirst(hookName, null, null, predicate);
}); });
it('custom predicate: normalized value passed to predicate (null)', async function () { it('custom predicate: normalized value passed to predicate (null)', async function () {
this.timeout(30);
hook.hook_fn = () => null; hook.hook_fn = () => null;
const predicate = (got) => { assert.deepEqual(got, [null]); }; const predicate = (got) => { assert.deepEqual(got, [null]); };
await hooks.aCallFirst(hookName, null, null, predicate); await hooks.aCallFirst(hookName, null, null, predicate);
}); });
it('non-empty arrays are returned unmodified', async function () { it('non-empty arrays are returned unmodified', async function () {
this.timeout(30);
const want = ['val1']; const want = ['val1'];
testHooks.length = 0; testHooks.length = 0;
testHooks.push(makeHook(want), makeHook(['val2'])); testHooks.push(makeHook(want), makeHook(['val2']));
@ -1292,7 +1199,6 @@ describe(__filename, function () {
}); });
it('value can be passed via callback', async function () { it('value can be passed via callback', async function () {
this.timeout(30);
const want = {}; const want = {};
hook.hook_fn = (hn, ctx, cb) => { cb(want); }; hook.hook_fn = (hn, ctx, cb) => { cb(want); };
const got = await hooks.aCallFirst(hookName); const got = await hooks.aCallFirst(hookName);

View file

@ -24,7 +24,6 @@ describe(__filename, function () {
}); });
it('regression test for missing await in createAuthor (#5000)', async function () { it('regression test for missing await in createAuthor (#5000)', async function () {
this.timeout(700);
const {authorID} = await AuthorManager.createAuthor(); // Should block until db.set() finishes. const {authorID} = await AuthorManager.createAuthor(); // Should block until db.set() finishes.
assert(await AuthorManager.doesAuthorExist(authorID)); assert(await AuthorManager.doesAuthorExist(authorID));
}); });

View file

@ -2,96 +2,11 @@
const assert = require('assert').strict; const assert = require('assert').strict;
const common = require('../common'); const common = require('../common');
const io = require('socket.io-client');
const padManager = require('../../../node/db/PadManager'); const padManager = require('../../../node/db/PadManager');
const plugins = require('../../../static/js/pluginfw/plugin_defs'); const plugins = require('../../../static/js/pluginfw/plugin_defs');
const readOnlyManager = require('../../../node/db/ReadOnlyManager'); const readOnlyManager = require('../../../node/db/ReadOnlyManager');
const setCookieParser = require('set-cookie-parser');
const settings = require('../../../node/utils/Settings'); const settings = require('../../../node/utils/Settings');
const socketIoRouter = require('../../../node/handler/SocketIORouter');
const logger = common.logger;
// Waits for and returns the next named socket.io event. Rejects if there is any error while waiting
// (unless waiting for that error event).
const getSocketEvent = async (socket, event) => {
const errorEvents = [
'error',
'connect_error',
'connect_timeout',
'reconnect_error',
'reconnect_failed',
];
const handlers = {};
let timeoutId;
return new Promise((resolve, reject) => {
timeoutId = setTimeout(() => reject(new Error(`timed out waiting for ${event} event`)), 1000);
for (const event of errorEvents) {
handlers[event] = (errorString) => {
logger.debug(`socket.io ${event} event: ${errorString}`);
reject(new Error(errorString));
};
}
// This will overwrite one of the above handlers if the user is waiting for an error event.
handlers[event] = (...args) => {
logger.debug(`socket.io ${event} event`);
if (args.length > 1) return resolve(args);
resolve(args[0]);
};
Object.entries(handlers).forEach(([event, handler]) => socket.on(event, handler));
}).finally(() => {
clearTimeout(timeoutId);
Object.entries(handlers).forEach(([event, handler]) => socket.off(event, handler));
});
};
// Establishes a new socket.io connection. Passes the cookies from the `set-cookie` header(s) in
// `res` (which may be nullish) to the server. Returns a socket.io Socket object.
const connect = async (res) => {
// Convert the `set-cookie` header(s) into a `cookie` header.
const resCookies = (res == null) ? {} : setCookieParser.parse(res, {map: true});
const reqCookieHdr = Object.entries(resCookies).map(
([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`).join('; ');
logger.debug('socket.io connecting...');
let padId = null;
if (res) {
padId = res.req.path.split('/p/')[1];
}
const socket = io(`${common.baseUrl}/`, {
forceNew: true, // Different tests will have different query parameters.
path: '/socket.io',
// socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the
// express_sid cookie must be passed as a query parameter.
query: {cookie: reqCookieHdr, padId},
});
try {
await getSocketEvent(socket, 'connect');
} catch (e) {
socket.close();
throw e;
}
logger.debug('socket.io connected');
return socket;
};
// Helper function to exchange CLIENT_READY+CLIENT_VARS messages for the named pad.
// Returns the CLIENT_VARS message from the server.
const handshake = async (socket, padID) => {
logger.debug('sending CLIENT_READY...');
socket.send({
component: 'pad',
type: 'CLIENT_READY',
padId: padID,
sessionID: null,
token: 't.12345',
protocolVersion: 2,
});
logger.debug('waiting for CLIENT_VARS response...');
const msg = await getSocketEvent(socket, 'message');
logger.debug('received CLIENT_VARS message');
return msg;
};
describe(__filename, function () { describe(__filename, function () {
this.timeout(30000); this.timeout(30000);
@ -142,38 +57,33 @@ describe(__filename, function () {
describe('Normal accesses', function () { describe('Normal accesses', function () {
it('!authn anonymous cookie /p/pad -> 200, ok', async function () { it('!authn anonymous cookie /p/pad -> 200, ok', async function () {
this.timeout(600);
const res = await agent.get('/p/pad').expect(200); const res = await agent.get('/p/pad').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
}); });
it('!authn !cookie -> ok', async function () { it('!authn !cookie -> ok', async function () {
this.timeout(400); socket = await common.connect(null);
socket = await connect(null); const clientVars = await common.handshake(socket, 'pad');
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
}); });
it('!authn user /p/pad -> 200, ok', async function () { it('!authn user /p/pad -> 200, ok', async function () {
this.timeout(400);
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
}); });
it('authn user /p/pad -> 200, ok', async function () { it('authn user /p/pad -> 200, ok', async function () {
this.timeout(400);
settings.requireAuthentication = true; settings.requireAuthentication = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
}); });
for (const authn of [false, true]) { for (const authn of [false, true]) {
const desc = authn ? 'authn user' : '!authn anonymous'; const desc = authn ? 'authn user' : '!authn anonymous';
it(`${desc} read-only /p/pad -> 200, ok`, async function () { it(`${desc} read-only /p/pad -> 200, ok`, async function () {
this.timeout(400);
const get = (ep) => { const get = (ep) => {
let res = agent.get(ep); let res = agent.get(ep);
if (authn) res = res.auth('user', 'user-password'); if (authn) res = res.auth('user', 'user-password');
@ -181,32 +91,30 @@ describe(__filename, function () {
}; };
settings.requireAuthentication = authn; settings.requireAuthentication = authn;
let res = await get('/p/pad'); let res = await get('/p/pad');
socket = await connect(res); socket = await common.connect(res);
let clientVars = await handshake(socket, 'pad'); let clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false); assert.equal(clientVars.data.readonly, false);
const readOnlyId = clientVars.data.readOnlyId; const readOnlyId = clientVars.data.readOnlyId;
assert(readOnlyManager.isReadOnlyId(readOnlyId)); assert(readOnlyManager.isReadOnlyId(readOnlyId));
socket.close(); socket.close();
res = await get(`/p/${readOnlyId}`); res = await get(`/p/${readOnlyId}`);
socket = await connect(res); socket = await common.connect(res);
clientVars = await handshake(socket, readOnlyId); clientVars = await common.handshake(socket, readOnlyId);
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true); assert.equal(clientVars.data.readonly, true);
}); });
} }
it('authz user /p/pad -> 200, ok', async function () { it('authz user /p/pad -> 200, ok', async function () {
this.timeout(400);
settings.requireAuthentication = true; settings.requireAuthentication = true;
settings.requireAuthorization = true; settings.requireAuthorization = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
}); });
it('supports pad names with characters that must be percent-encoded', async function () { it('supports pad names with characters that must be percent-encoded', async function () {
this.timeout(400);
settings.requireAuthentication = true; settings.requireAuthentication = true;
// requireAuthorization is set to true here to guarantee that the user's padAuthorizations // requireAuthorization is set to true here to guarantee that the user's padAuthorizations
// object is populated. Technically this isn't necessary because the user's padAuthorizations // object is populated. Technically this isn't necessary because the user's padAuthorizations
@ -215,58 +123,54 @@ describe(__filename, function () {
settings.requireAuthorization = true; settings.requireAuthorization = true;
const encodedPadId = encodeURIComponent('päd'); const encodedPadId = encodeURIComponent('päd');
const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200); const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'päd'); const clientVars = await common.handshake(socket, 'päd');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
}); });
}); });
describe('Abnormal access attempts', function () { describe('Abnormal access attempts', function () {
it('authn anonymous /p/pad -> 401, error', async function () { it('authn anonymous /p/pad -> 401, error', async function () {
this.timeout(400);
settings.requireAuthentication = true; settings.requireAuthentication = true;
const res = await agent.get('/p/pad').expect(401); const res = await agent.get('/p/pad').expect(401);
// Despite the 401, try to create the pad via a socket.io connection anyway. // Despite the 401, try to create the pad via a socket.io connection anyway.
socket = await connect(res); socket = await common.connect(res);
const message = await handshake(socket, 'pad'); const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
it('authn anonymous read-only /p/pad -> 401, error', async function () { it('authn anonymous read-only /p/pad -> 401, error', async function () {
this.timeout(400);
settings.requireAuthentication = true; settings.requireAuthentication = true;
let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
const readOnlyId = clientVars.data.readOnlyId; const readOnlyId = clientVars.data.readOnlyId;
assert(readOnlyManager.isReadOnlyId(readOnlyId)); assert(readOnlyManager.isReadOnlyId(readOnlyId));
socket.close(); socket.close();
res = await agent.get(`/p/${readOnlyId}`).expect(401); res = await agent.get(`/p/${readOnlyId}`).expect(401);
// Despite the 401, try to read the pad via a socket.io connection anyway. // Despite the 401, try to read the pad via a socket.io connection anyway.
socket = await connect(res); socket = await common.connect(res);
const message = await handshake(socket, readOnlyId); const message = await common.handshake(socket, readOnlyId);
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
it('authn !cookie -> error', async function () { it('authn !cookie -> error', async function () {
this.timeout(400);
settings.requireAuthentication = true; settings.requireAuthentication = true;
socket = await connect(null); socket = await common.connect(null);
const message = await handshake(socket, 'pad'); const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
it('authorization bypass attempt -> error', async function () { it('authorization bypass attempt -> error', async function () {
this.timeout(400);
// Only allowed to access /p/pad. // Only allowed to access /p/pad.
authorize = (req) => req.path === '/p/pad'; authorize = (req) => req.path === '/p/pad';
settings.requireAuthentication = true; settings.requireAuthentication = true;
settings.requireAuthorization = true; settings.requireAuthorization = true;
// First authenticate and establish a session. // First authenticate and establish a session.
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
// Accessing /p/other-pad should fail, despite the successful fetch of /p/pad. // Accessing /p/other-pad should fail, despite the successful fetch of /p/pad.
const message = await handshake(socket, 'other-pad'); const message = await common.handshake(socket, 'other-pad');
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
}); });
@ -278,66 +182,59 @@ describe(__filename, function () {
}); });
it("level='create' -> can create", async function () { it("level='create' -> can create", async function () {
this.timeout(400);
authorize = () => 'create'; authorize = () => 'create';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false); assert.equal(clientVars.data.readonly, false);
}); });
it('level=true -> can create', async function () { it('level=true -> can create', async function () {
this.timeout(400);
authorize = () => true; authorize = () => true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false); assert.equal(clientVars.data.readonly, false);
}); });
it("level='modify' -> can modify", async function () { it("level='modify' -> can modify", async function () {
this.timeout(400);
await padManager.getPad('pad'); // Create the pad. await padManager.getPad('pad'); // Create the pad.
authorize = () => 'modify'; authorize = () => 'modify';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false); assert.equal(clientVars.data.readonly, false);
}); });
it("level='create' settings.editOnly=true -> unable to create", async function () { it("level='create' settings.editOnly=true -> unable to create", async function () {
this.timeout(400);
authorize = () => 'create'; authorize = () => 'create';
settings.editOnly = true; settings.editOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const message = await handshake(socket, 'pad'); const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
it("level='modify' settings.editOnly=false -> unable to create", async function () { it("level='modify' settings.editOnly=false -> unable to create", async function () {
this.timeout(400);
authorize = () => 'modify'; authorize = () => 'modify';
settings.editOnly = false; settings.editOnly = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const message = await handshake(socket, 'pad'); const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
it("level='readOnly' -> unable to create", async function () { it("level='readOnly' -> unable to create", async function () {
this.timeout(400);
authorize = () => 'readOnly'; authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const message = await handshake(socket, 'pad'); const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
it("level='readOnly' -> unable to modify", async function () { it("level='readOnly' -> unable to modify", async function () {
this.timeout(400);
await padManager.getPad('pad'); // Create the pad. await padManager.getPad('pad'); // Create the pad.
authorize = () => 'readOnly'; authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true); assert.equal(clientVars.data.readonly, true);
}); });
@ -349,56 +246,50 @@ describe(__filename, function () {
}); });
it('user.canCreate = true -> can create and modify', async function () { it('user.canCreate = true -> can create and modify', async function () {
this.timeout(400);
settings.users.user.canCreate = true; settings.users.user.canCreate = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false); assert.equal(clientVars.data.readonly, false);
}); });
it('user.canCreate = false -> unable to create', async function () { it('user.canCreate = false -> unable to create', async function () {
this.timeout(400);
settings.users.user.canCreate = false; settings.users.user.canCreate = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const message = await handshake(socket, 'pad'); const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
it('user.readOnly = true -> unable to create', async function () { it('user.readOnly = true -> unable to create', async function () {
this.timeout(400);
settings.users.user.readOnly = true; settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const message = await handshake(socket, 'pad'); const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
it('user.readOnly = true -> unable to modify', async function () { it('user.readOnly = true -> unable to modify', async function () {
this.timeout(400);
await padManager.getPad('pad'); // Create the pad. await padManager.getPad('pad'); // Create the pad.
settings.users.user.readOnly = true; settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true); assert.equal(clientVars.data.readonly, true);
}); });
it('user.readOnly = false -> can create and modify', async function () { it('user.readOnly = false -> can create and modify', async function () {
this.timeout(400);
settings.users.user.readOnly = false; settings.users.user.readOnly = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const clientVars = await handshake(socket, 'pad'); const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false); assert.equal(clientVars.data.readonly, false);
}); });
it('user.readOnly = true, user.canCreate = true -> unable to create', async function () { it('user.readOnly = true, user.canCreate = true -> unable to create', async function () {
this.timeout(400);
settings.users.user.canCreate = true; settings.users.user.canCreate = true;
settings.users.user.readOnly = true; settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const message = await handshake(socket, 'pad'); const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
}); });
@ -410,23 +301,126 @@ describe(__filename, function () {
}); });
it('authorize hook does not elevate level from user settings', async function () { it('authorize hook does not elevate level from user settings', async function () {
this.timeout(400);
settings.users.user.readOnly = true; settings.users.user.readOnly = true;
authorize = () => 'create'; authorize = () => 'create';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const message = await handshake(socket, 'pad'); const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
it('user settings does not elevate level from authorize hook', async function () { it('user settings does not elevate level from authorize hook', async function () {
this.timeout(400);
settings.users.user.readOnly = false; settings.users.user.readOnly = false;
settings.users.user.canCreate = true; settings.users.user.canCreate = true;
authorize = () => 'readOnly'; authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res); socket = await common.connect(res);
const message = await handshake(socket, 'pad'); const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny'); assert.equal(message.accessStatus, 'deny');
}); });
}); });
describe('SocketIORouter.js', function () {
const Module = class {
setSocketIO(io) {}
handleConnect(socket) {}
handleDisconnect(socket) {}
handleMessage(socket, message) {}
};
afterEach(async function () {
socketIoRouter.deleteComponent(this.test.fullTitle());
socketIoRouter.deleteComponent(`${this.test.fullTitle()} #2`);
});
it('setSocketIO', async function () {
let ioServer;
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
setSocketIO(io) { ioServer = io; }
}());
assert(ioServer != null);
});
it('handleConnect', async function () {
let serverSocket;
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
handleConnect(socket) { serverSocket = socket; }
}());
socket = await common.connect();
assert(serverSocket != null);
});
it('handleDisconnect', async function () {
let resolveConnected;
const connected = new Promise((resolve) => resolveConnected = resolve);
let resolveDisconnected;
const disconnected = new Promise((resolve) => resolveDisconnected = resolve);
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
handleConnect(socket) {
this._socket = socket;
resolveConnected();
}
handleDisconnect(socket) {
assert(socket != null);
// There might be lingering disconnect events from sockets created by other tests.
if (this._socket == null || socket.id !== this._socket.id) return;
assert.equal(socket, this._socket);
resolveDisconnected();
}
}());
socket = await common.connect();
await connected;
socket.close();
socket = null;
await disconnected;
});
it('handleMessage (success)', async function () {
let serverSocket;
const want = {
component: this.test.fullTitle(),
foo: {bar: 'asdf'},
};
let rx;
const got = new Promise((resolve) => { rx = resolve; });
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
handleConnect(socket) { serverSocket = socket; }
handleMessage(socket, message) { assert.equal(socket, serverSocket); rx(message); }
}());
socketIoRouter.addComponent(`${this.test.fullTitle()} #2`, new class extends Module {
handleMessage(socket, message) { assert.fail('wrong handler called'); }
}());
socket = await common.connect();
socket.send(want);
assert.deepEqual(await got, want);
});
const tx = async (socket, message = {}) => await new Promise((resolve, reject) => {
const AckErr = class extends Error {
constructor(name, ...args) { super(...args); this.name = name; }
};
socket.send(message,
(errj, val) => errj != null ? reject(new AckErr(errj.name, errj.message)) : resolve(val));
});
it('handleMessage with ack (success)', async function () {
const want = 'value';
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
handleMessage(socket, msg) { return want; }
}());
socket = await common.connect();
const got = await tx(socket, {component: this.test.fullTitle()});
assert.equal(got, want);
});
it('handleMessage with ack (error)', async function () {
const InjectedError = class extends Error {
constructor() { super('injected test error'); this.name = 'InjectedError'; }
};
socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
handleMessage(socket, msg) { throw new InjectedError(); }
}());
socket = await common.connect();
await assert.rejects(tx(socket, {component: this.test.fullTitle()}), new InjectedError());
});
});
}); });

View file

@ -1,3 +1,5 @@
'use strict';
const common = require('../common'); const common = require('../common');
const settings = require('../../../node/utils/Settings'); const settings = require('../../../node/utils/Settings');
@ -20,7 +22,6 @@ describe(__filename, function () {
describe('/javascript', function () { describe('/javascript', function () {
it('/javascript -> 200', async function () { it('/javascript -> 200', async function () {
this.timeout(200);
await agent.get('/javascript').expect(200); await agent.get('/javascript').expect(200);
}); });
}); });

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