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
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]
- Node.js version (`node --version`):
- npm version (`npm --version`):
- Is the server free of plugins:
**Desktop (please complete the following information):**
- 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.
2. PR name follows [convention](http://karma-runner.github.io/4.0/dev/git-commit-msg.html)
3. All tests pass locally, UI and Unit tests
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
If you're making a big change, please explain what problem it solves:
- Explain the purpose of the change. When adding a way to do X, explain why it is important to be able to do X.
- Show the current vs desired behavior with screenshots/GIFs.
-->
> 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
- name: Install all dependencies and symlink for ep_etherpad-lite
run: |
cd src
npm ci --no-optional
run: src/bin/installOnWindows.bat
- name: Fix up the settings.json
run: |
@ -172,9 +170,7 @@ jobs:
# if npm correctly hoists the dependencies, the hoisting seems to confuse
# tools such as `npm outdated`, `npm update`, and some ESLint rules.
- name: Install all dependencies and symlink for ep_etherpad-lite
run: |
cd src
npm ci --no-optional
run: src/bin/installOnWindows.bat
- name: Fix up the settings.json
run: |

View file

@ -57,6 +57,9 @@ jobs:
- 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"
- 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
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
npm-debug.log
*.DS_Store
.ep_initialized
*.crt
*.key
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
### Security fixes

View file

@ -15,9 +15,17 @@
number of the issue that is being fixed, in the form: Fixes #someIssueNumber
```
* if the PR is a **bug fix**:
* the first commit in the series must be a test that shows the failure
* subsequent commits will fix the bug and make the test pass
* the final commit message should include the text `Fixes: #xxx` to link it to its bug report
* The commit that fixes the bug should **include a regression test** that
would fail if the bug fix was reverted. Adding the regression test in the
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**
* if you want to remove a feature, **deprecate it instead**:
* 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 \
ca-certificates \
git \
curl \
${INSTALL_ABIWORD:+abiword} \
${INSTALL_SOFFICE:+libreoffice} \
&& \
@ -94,5 +95,7 @@ COPY --chown=etherpad:etherpad ./settings.json.docker "${EP_DIR}"/settings.json
# Fix group permissions
RUN chmod -R g=u .
HEALTHCHECK --interval=20s --timeout=3s CMD curl -f http://localhost:9001 || exit 1
EXPOSE 9001
CMD ["node", "src/node/server.js"]

View file

@ -1,156 +1,44 @@
# Changeset Library
```
"Z:z>1|2=m=b*0|1+1$\n"
The [changeset
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.
## Changeset.unpack(changeset)
* `changeset` {String}
This function returns an object representation of the changeset, similar to this:
A transmitted changeset looks like 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.
* `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.
## Attribute Pool
## Changeset.opIterator(ops)
* `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' }
```javascript
const AttributePool = require('ep_etherpad-lite/static/js/AttributePool');
```
## 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.
```
> var AttributePoolFactory = require("./utils/AttributePoolFactory");
> 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] }
```
There is one attribute pool per pad, and it includes every current and
historical attribute used in the pad.
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
```
> 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
## Further Reading
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)
* 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)
* [Easysync Protocol](https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-notes.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()
## toggleDropDown(dropdown, callback)
## toggleDropDown(dropdown)
Shows the dropdown `div.popup` whose `id` equals `dropdown`.
## registerCommand(cmd, callback)

View file

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

View file

@ -229,7 +229,10 @@ Called from: src/static/js/pad.js
Things in context:
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
@ -276,29 +279,53 @@ Things in context:
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.
## 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
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)
Context properties:
This hook is called on the client side whenever a chat message is received from
the server. It can be used to create different notifications for chat messages.
Hoook functions can modify the `author`, `authorName`, `duration`, `sticky`,
`text`, and `timeStr` context properties to change how the message is processed.
The `text` and `timeStr` properties may contain HTML, but plugins should be
careful to sanitize any added user input to avoid introducing an XSS
vulnerability.
* `authorName`: The display name of the user that wrote the message.
* `author`: The author ID of the user that wrote the message.
* `text`: Sanitized message HTML, with URLs wrapped like `<a
href="url">url</a>`. (Note that `message.text` is not sanitized or processed
in any way.)
* `message`: The raw message object as received from the server, except with
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

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.
## init_`<plugin name>`
Called from: src/static/js/pluginfw/plugins.js
## `init_<plugin name>`
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
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
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
connection until the next `CLIENT_READY` or `SWITCH_TO_PAD` message. Read-only
access is reset **after** each `CLIENT_READY` or `SWITCH_TO_PAD` message, so
granting write access has no effect for those message types.
connection until the next `CLIENT_READY` message. Read-only access is reset
**after** each `CLIENT_READY` message, so granting write access has no effect
for those message types.
The handleMessageSecurity function must return a Promise. If the Promise
resolves to `true`, write access is granted as described above. Returning
@ -807,36 +808,82 @@ Example:
exports.exportEtherpadAdditionalContent = () => ['comments'];
```
## userLeave
Called from src/node/handler/PadMessageHandler.js
## `import`
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:
```
exports.userLeave = function(hook, session, callback) {
console.log('%s left pad %s', session.author, session.padId);
```javascript
exports.userJoin = async (hookName, {authorId, displayName, padId}) => {
console.log(`${authorId} (${displayName}) joined pad ${padId});
};
```
### clientReady
Called from src/node/handler/PadMessageHandler.js
## `userLeave`
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:
```
exports.clientReady = function(hook, message) {
console.log('Client has entered the pad' + message.padId);
```javascript
exports.userLeave = async (hookName, {author, 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.
### 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`.
@ -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_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` |
| `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` |
| `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` |

View file

@ -213,7 +213,9 @@
"user": "${DB_USER:undefined}",
"password": "${DB_PASS: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
* 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
*/
"dumpOnUncleanExit": false,
"dumpOnUncleanExit": "${DUMP_ON_UNCLEAN_EXIT:false}",
/*
* Disable indentation on new line when previous line ends with some special
@ -584,58 +586,6 @@
*/
"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 */
"customLocaleStrings": {}
}

View file

@ -590,58 +590,6 @@
*/
"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 */
"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 )"
# 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
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
settings="settings.json"
@ -34,17 +36,14 @@ if [ ! -f "$settings" ]; then
cp settings.json.template "$settings" || exit 1
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
cd node_modules
[ -e ep_etherpad-lite ] || ln -s ../src ep_etherpad-lite
cd ep_etherpad-lite
mkdir -p node_modules &&
cd node_modules &&
{ [ -d ep_etherpad-lite ] || ln -sf ../src ep_etherpad-lite; } &&
cd ep_etherpad-lite &&
npm ci --no-optional
) || {
rm -rf src/node_modules
exit 1
}
) || exit 1
# Remove all minified data to force node creating it new
log "Clearing minified cache..."

View file

@ -1,7 +1,7 @@
@echo off
:: Change directory to etherpad-lite root
cd /D "%~dp0\.."
cd /D "%~dp0\..\.."
:: Is node installed?
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"
cmd /C npm ci || exit /B 1
cd /D "%~dp0\.."
cd /D "%~dp0\..\.."
echo _
echo Clearing cache...

View file

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

View file

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

View file

@ -5,23 +5,38 @@
"Aftabuzzaman",
"Al Riaz Uddin Ripon",
"Bellayet",
"Greatder",
"Nasir8891",
"Sankarshan",
"Sibabrata Banerjee",
"আফতাবুজ্জামান"
]
},
"admin.page-title": "প্রশাসক কেন্দ্র - ইথারপ্যাড",
"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_uninstall.value": "আনইনস্টল করুন",
"admin_plugins.last-update": "সর্বশেষ হালনাগাদ",
"admin_plugins.name": "নাম",
"admin_plugins.page-title": "প্লাগিন ব্যবস্থাপনা - ইথারপ্যাড",
"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_latest": "সাম্প্রতিক উপলব্ধ সংস্করণ",
"admin_plugins_info.version_number": "সংস্করণ সংখ্যা",
"admin_settings": "সেটিংসমূহ",
"admin_settings.current": "বর্তমান কনফিগারেশন",
"admin_settings.current_restart.value": "ইথারপ্যাড পুনরায় চালু করুন",

View file

@ -5,8 +5,10 @@
"Espeox",
"Jl",
"Lliehu",
"MITO",
"Maantietäjä",
"Macofe",
"Markus Mikkonen",
"MrTapsa",
"Nedergard",
"Nike",
@ -18,8 +20,11 @@
"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_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.description": "Kuvaus",
"admin_plugins.installed": "Asennetut laajennukset",
@ -43,7 +48,7 @@
"admin_settings": "Asetukset",
"admin_settings.current": "Nykyinen kokoonpano",
"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",
"index.newPad": "Uusi muistio",
"index.createOpenPad": "tai luo tai avaa muistio nimellä:",
@ -67,7 +72,7 @@
"pad.colorpicker.save": "Tallenna",
"pad.colorpicker.cancel": "Peru",
"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.settings.padSettings": "Muistion asetukset",
"pad.settings.myView": "Oma näkymä",

View file

@ -21,7 +21,7 @@
"pad.toolbar.timeslider.title": "Glissa-tempore",
"pad.toolbar.savedRevision.title": "Version salveguardate",
"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.colorpicker.save": "Salveguardar",
"pad.colorpicker.cancel": "Cancellar",

View file

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

View file

@ -77,7 +77,7 @@
"pad.settings.about": "За додатоков",
"pad.settings.poweredBy": "Овозможено од",
"pad.importExport.import_export": "Увоз/Извоз",
"pad.importExport.import": "Подигање на било каква текстуална податотека или документ",
"pad.importExport.import": "Подигање на било каква било текстуална податотека или документ",
"pad.importExport.importSuccessful": "Успешно!",
"pad.importExport.export": "Извези ја тековната тетратка како",
"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",
"Mainframe98",
"Marcelhospers",
"McDutchie",
"PonkoSasuke",
"Rickvl",
"Robin van der Vliet",
@ -57,7 +58,7 @@
"pad.toolbar.ol.title": "Geordende lijst (Ctrl+Shift+N)",
"pad.toolbar.ul.title": "Ongeordende lijst (Ctrl+Shift+L)",
"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.redo.title": "Opnieuw uitvoeren (Ctrl-Y)",
"pad.toolbar.clearAuthorship.title": "Kleuren auteurs wissen (Ctrl+Shift+C)",
@ -70,7 +71,7 @@
"pad.colorpicker.save": "Opslaan",
"pad.colorpicker.cancel": "Annuleren",
"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.settings.padSettings": "Padinstellingen",
"pad.settings.myView": "Mijn overzicht",
@ -93,9 +94,9 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "Pdf",
"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.reconnecting": "Opnieuw verbinding maken met uw pad...",
"pad.modals.reconnecting": "Opnieuw verbinding maken met uw pad",
"pad.modals.forcereconnect": "Opnieuw verbinden",
"pad.modals.reconnecttimer": "Proberen te verbinden over",
"pad.modals.cancel": "Annuleren",

View file

@ -49,7 +49,7 @@
"pad.modals.cancel": "رد",
"pad.modals.userdup": "هڪ ٻي دري ۾ کليل",
"pad.modals.unauth": "اختيار نه آهي",
"pad.modals.initsocketfail": "سَروَرَ کي پڄي نٿو سگھجي.",
"pad.modals.initsocketfail": "سَروَرَ تائين پُڄي نٿو سگهجي.",
"pad.modals.slowcommit.explanation": "سَروَر جواب نٿو ڏي.",
"pad.modals.corruptPad.explanation": "جيڪا پٽي توهان حاصل ڪرڻ چاهيو ٿا اها بدعنوان آهي.",
"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": {
"authors": [
"Aefgh39622",
"Andibecker",
"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.createOpenPad": "หรือสร้าง/เปิดแผ่นจดบันทึกที่มีชื่อ:",
"index.openPad": "เปิดแพดที่มีอยู่แล้วด้วยชื่อ:",
@ -77,6 +111,8 @@
"pad.modals.deleted.explanation": "แผ่นจดบันทึกนี้ได้ถูกลบออกแล้ว",
"pad.modals.rateLimited": "ถึงขีดจำกัด",
"pad.modals.rateLimited.explanation": "คณส่งข้อความถึงแพดนี้มากเกินไปจึงถูกตัดการเชื่อมโดยโปรแกรมอัตโนมัติ",
"pad.modals.rejected.explanation": "เซิร์ฟเวอร์ปฏิเสธข้อความที่ส่งโดยเบราว์เซอร์ของคุณ",
"pad.modals.rejected.cause": "เซิร์ฟเวอร์อาจได้รับการอัปเดตในขณะที่คุณกำลังดูแพด หรืออาจมีข้อบกพร่องใน Etherpad ลองโหลดหน้านี้ใหม่",
"pad.modals.disconnected": "คุณได้ตัดการเชื่อมต่อแล้ว",
"pad.modals.disconnected.explanation": "การเชื่อมต่อกับเซิร์ฟเวอร์ถูกตัด",
"pad.modals.disconnected.cause": "เซิร์ฟเวอร์อาจใช้ไม่ได้ชั่วคราว โปรดแจ้งให้ผู้ดูแลการให้บริการทราบถ้าปัญหานี้ยังคงเกิดขึ้น",

View file

@ -44,12 +44,12 @@
"admin_settings.current": "Geçerli yapılandırma",
"admin_settings.current_example-devel": "Örnek geliştirme 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.page-title": "Ayarlar - Etherpad",
"index.newPad": "Yeni Bloknot",
"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.italic.title": "Eğik (Ctrl+I)",
"pad.toolbar.underline.title": "Altı Çizili (Ctrl+U)",
@ -65,12 +65,12 @@
"pad.toolbar.timeslider.title": "Zaman Çizelgesi",
"pad.toolbar.savedRevision.title": "Düzeltmeyi Kaydet",
"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.colorpicker.save": "Kaydet",
"pad.colorpicker.cancel": "İptal",
"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.settings.padSettings": "Bloknot Ayarları",
"pad.settings.myView": "Görünümüm",
@ -83,7 +83,7 @@
"pad.settings.fontType.normal": "Olağan",
"pad.settings.language": "Dil:",
"pad.settings.about": "Hakkında",
"pad.settings.poweredBy": "Destekleyen",
"pad.settings.poweredBy": "Destekleyen:",
"pad.importExport.import_export": "İçe/Dışa aktar",
"pad.importExport.import": "Herhangi bir metin dosyası ya da belgesi yükle",
"pad.importExport.importSuccessful": "Başarılı!",
@ -94,37 +94,37 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"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.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.reconnecttimer": "Yeniden bağlanmaya çalışılıyor",
"pad.modals.cancel": "İptal",
"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.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.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.initsocketfail": "Sunucuya erişilemiyor.",
"pad.modals.initsocketfail.explanation": "Eşitleme sunucusuna bağlantı kurulamıyor.",
"pad.modals.initsocketfail": "Sunucuya ulaşılamı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.slowcommit.explanation": "Sunucu yanıt vermiyor.",
"pad.modals.slowcommit.cause": "Bu hata ağ bağlantısı sebebiyle 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.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.slowcommit.cause": "Bu durum, ağ bağlantısıyla ilgili sorunlardan kaynaklanıyor olabilir.",
"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 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.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.explanation": "Bu bloknot kaldırılmış.",
"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.cause": "Siz pedi 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.explanation": "Sunucu bağlantısı kaybedildi",
"pad.modals.disconnected.cause": "Sunucu kullanılamıyor olabilir. Bunun devam etmesi durumunda servis yöneticisine bildirin.",
"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 kesildi.",
"pad.modals.disconnected.explanation": "Sunucuyla bağlantı kesildi",
"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.readonly": "Yalnızca oku",
"pad.share.link": "Bağlantı",
@ -133,20 +133,20 @@
"pad.chat.title": "Bu bloknot için sohbeti açın.",
"pad.chat.loadmessages": "Daha fazla mesaj yükle",
"pad.chat.stick.title": "Sohbeti ekrana yapıştır",
"pad.chat.writeMessage.placeholder": "Mesajını buraya yaz",
"timeslider.followContents": "Pad içerik güncellemelerini takip edin",
"pad.chat.writeMessage.placeholder": "Mesajınızı buraya yazın",
"timeslider.followContents": "Bloknot içerik güncellemelerini takip edin",
"timeslider.pageTitle": "{{appTitle}} Zaman Çizelgesi",
"timeslider.toolbar.returnbutton": "Bloknota geri dön",
"timeslider.toolbar.authors": "Yazarlar:",
"timeslider.toolbar.authorsList": "Yazar Yok",
"timeslider.toolbar.exportlink.title": "Dışa aktar",
"timeslider.exportCurrent": "Mevcut sürümü şu olarak dışa aktar:",
"timeslider.version": "{{version}} sürümü",
"timeslider.exportCurrent": "Geçerli sürümü şu şekilde dışa aktar:",
"timeslider.version": "Sürüm {{version}}",
"timeslider.saved": "{{day}} {{month}} {{year}} tarihinde kaydedildi",
"timeslider.playPause": "Bloknot İçeriğini Oynat / Durdur",
"timeslider.backRevision": "Bu bloknottaki bir revizyona geri git",
"timeslider.forwardRevision": "Bu bloknatta sonraki revizyona git",
"timeslider.dateformat": "{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}",
"timeslider.backRevision": "Bu bloknottaki bir sürüme geri dön",
"timeslider.forwardRevision": "Bu bloknatta sonraki sürüme git",
"timeslider.dateformat": "{{day}}.{{month}}.{{year}} {{hours}}.{{minutes}}.{{seconds}}",
"timeslider.month.january": "Ocak",
"timeslider.month.february": "Şubat",
"timeslider.month.march": "Mart",
@ -160,19 +160,19 @@
"timeslider.month.november": "Kasım",
"timeslider.month.december": "Aralık",
"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.timeslider": "Zaman kaydırıcısını ziyaret ederek kaydedilen revizyonları görebilirsiniz",
"pad.savedrevs.marked": "Bu sürüm, artık kaydedilmiş bir sürüm olarak işaretlendi.",
"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.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.importing": "İçe aktarı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.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.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.uploadFailed": "Yükleme başarısız, lütfen tekrar deneyin",
"pad.impexp.importfailed": "İçe aktarım başarısız oldu",
"pad.impexp.importing": "İçe aktarılıyor...",
"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 aktaramadık. Lütfen farklı bir belge biçimi kullanın veya elle kopyalayıp yapıştırı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 oldu, lütfen tekrar deneyin.",
"pad.impexp.importfailed": "İçe aktarılamadı",
"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"
}

View file

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

View file

@ -3,6 +3,7 @@
"authors": [
"94rain",
"Dimension",
"GuoPC",
"Hydra",
"Hzy980512",
"JuneAugust",
@ -67,7 +68,7 @@
"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.modals.connected": "已连接。",
"pad.modals.reconnecting": "重新连接到您的记事本...",
"pad.modals.reconnecting": "重新连接到您的记事本",
"pad.modals.forcereconnect": "强制重新连接",
"pad.modals.reconnecttimer": "尝试重新连入",
"pad.modals.cancel": "取消",

View file

@ -20,6 +20,7 @@
*/
const Changeset = require('../../static/js/Changeset');
const ChatMessage = require('../../static/js/ChatMessage');
const CustomError = require('../utils/customError');
const padManager = require('./PadManager');
const padMessageHandler = require('../handler/PadMessageHandler');
@ -364,7 +365,7 @@ exports.appendChatMessage = async (padID, text, authorID, time) => {
// @TODO - missing getPadSafe() call ?
// 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 ChatMessage = require('../../static/js/ChatMessage');
const AttributePool = require('../../static/js/AttributePool');
const db = require('./DB');
const settings = require('../utils/Settings');
@ -258,7 +259,7 @@ Pad.prototype.setText = async function (newText) {
}
// append the changeset
await this.appendRevision(changeset);
if (newText !== oldText) await this.appendRevision(changeset);
};
Pad.prototype.appendText = async function (newText) {
@ -274,53 +275,61 @@ Pad.prototype.appendText = async function (newText) {
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++;
// save the chat entry in the database
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(),
]);
};
/**
* @param {number} entryNum - ID of the desired chat message.
* @returns {?ChatMessage}
*/
Pad.prototype.getChatMessage = async function (entryNum) {
// get the chat entry
const entry = await db.get(`pad:${this.id}:chat:${entryNum}`);
// get the authorName if the entry exists
if (entry != null) {
entry.userName = await authorManager.getAuthorName(entry.userId);
}
return entry;
if (entry == null) return null;
const message = ChatMessage.fromObject(entry);
message.displayName = await authorManager.getAuthorName(message.authorId);
return message;
};
/**
* @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) {
// collect the numbers of chat entries and in which order we need them
const neededEntries = [];
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;
})));
const entries = await Promise.all(
[...Array(end + 1 - start).keys()].map((i) => this.getChatMessage(start + i)));
// sort out broken chat entries
// it looks like in happened in the past that the chat head was
// incremented, but the chat message wasn't added
const cleanedEntries = entries.filter((entry) => {
return entries.filter((entry) => {
const pass = (entry != null);
if (!pass) {
console.warn(`WARNING: Found broken chat entry in pad ${this.id}`);
}
return pass;
});
return cleanedEntries;
};
Pad.prototype.init = async function (text) {
@ -490,7 +499,6 @@ Pad.prototype.copyPadWithoutHistory = async function (destinationID, force) {
// based on Changeset.makeSplice
const assem = Changeset.smartOpAssembler();
assem.appendOpWithText('=', '');
Changeset.appendATextToAssembler(oldAText, assem);
assem.endDocument();

View file

@ -255,7 +255,7 @@ const listSessionsWithDBKey = async (dbkey) => {
const sessionInfo = await exports.getSessionInfo(sessionID);
sessions[sessionID] = sessionInfo;
} catch (err) {
if (err === 'apierror: sessionID does not exist') {
if (err.name === 'apierror') {
console.warn(`Found bad session ${sessionID} in ${dbkey}`);
sessions[sessionID] = null;
} 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
// We have to over engineer this because tabs are stored as attributes and not plain text
if (type === 'etherpad') {
const pad = await exportEtherpad.getPadRaw(padId);
const pad = await exportEtherpad.getPadRaw(padId, readOnlyId);
res.send(pad);
} else if (type === 'txt') {
const txt = await exporttxt.getPadTXTDocument(padId, req.params.rev);
res.send(txt);
} else {
// render the html document
let html = await exporthtml.getPadHTMLDocument(padId, req.params.rev);
let html = await exporthtml.getPadHTMLDocument(padId, req.params.rev, readOnlyId);
// 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}`);
// Logic for allowing external Import Plugins
const result = await hooks.aCallAll('import', {srcFile, destFile, fileEnding});
const importHandledByPlugin = (result.length > 0); // This feels hacky and wrong..
const importHandledByPlugin =
(await hooks.aCallAll('import', {srcFile, destFile, fileEnding, padId})).some((x) => x);
const fileIsEtherpad = (fileEnding === '.etherpad');
const fileIsHTML = (fileEnding === '.html' || fileEnding === '.htm');
const fileIsTXT = (fileEnding === '.txt');
@ -235,8 +232,8 @@ const doImport = async (req, res, padId) => {
pad = await padManager.getPad(padId);
padManager.unloadPad(padId);
// direct Database Access means a pad user should perform a switchToPad
// and not attempt to receive updated pad data
// Direct database access means a pad user should reload the pad and not attempt to receive
// updated pad data.
if (directDatabaseAccess) return true;
// tell clients to update

View file

@ -21,6 +21,7 @@
const padManager = require('../db/PadManager');
const Changeset = require('../../static/js/Changeset');
const ChatMessage = require('../../static/js/ChatMessage');
const AttributePool = require('../../static/js/AttributePool');
const AttributeManager = require('../../static/js/AttributeManager');
const authorManager = require('../db/AuthorManager');
@ -33,14 +34,15 @@ const messageLogger = log4js.getLogger('message');
const accessLogger = log4js.getLogger('access');
const _ = require('underscore');
const hooks = require('../../static/js/pluginfw/hooks.js');
const channels = require('channels');
const stats = require('../stats');
const assert = require('assert').strict;
const nodeify = require('nodeify');
const {RateLimiterMemory} = require('rate-limiter-flexible');
const webaccess = require('../hooks/express/webaccess');
let rateLimiter;
let socketio = null;
hooks.deprecationNotices.clientReady = 'use the userJoin hook instead';
exports.socketio = () => {
// 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.
* - author: The user's author ID.
* - 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).
* - rev: The last revision that was sent to the client.
*/
const 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', () => {
const padIds = new Set();
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(
({socket, message}, callback) => nodeify(handleUserChanges(socket, message), callback)
);
class Channels {
/**
* @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
@ -130,45 +159,35 @@ exports.kickSessionsFromPad = (padID) => {
*/
exports.handleDisconnect = async (socket) => {
stats.meter('disconnects').mark();
// save the padname of this session
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) {
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;
accessLogger.info(`${'[LEAVE]' +
/* 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}` : ''}`);
// 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 = {
` 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: color,
colorId: await authorManager.getAuthorColorId(session.author),
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];
});
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 {
await rateLimiter.consume(socket.request.ip); // consume 1 point per event from IP
} catch (e) {
console.warn(`Rate limited: ${socket.request.ip} to reduce the amount of rate limiting ` +
'that happens edit the rateLimit values in settings.json');
messageLogger.warn(`Rate limited IP ${socket.request.ip}. To reduce the amount of rate ` +
'limiting that happens edit the rateLimit values in settings.json');
stats.meter('rateLimited').mark();
socket.json.send({disconnect: 'rateLimited'});
return;
@ -207,8 +226,14 @@ exports.handleMessage = async (socket, message) => {
}
if (message.type === 'CLIENT_READY') {
// client tried to auth for the first time (first msg from the client)
createSessionInfoAuth(thisSession, 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.
thisSession.auth = {
sessionID: message.sessionID,
padID: message.padId,
token: message.token,
};
}
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
if (message.type === 'CLIENT_READY') {
await handleClientReady(socket, message, authorID);
await handleClientReady(socket, message);
} else if (message.type === 'CHANGESET_REQ') {
await handleChangesetRequest(socket, message);
} else if (message.type === 'COLLABROOM') {
@ -271,7 +296,7 @@ exports.handleMessage = async (socket, message) => {
messageLogger.warn('Dropped message, COLLABROOM for readonly pad');
} else if (message.data.type === 'USER_CHANGES') {
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') {
await handleUserInfoUpdate(socket, message);
} else if (message.data.type === 'CHAT_MESSAGE') {
@ -287,8 +312,6 @@ exports.handleMessage = async (socket, message) => {
} else {
messageLogger.warn(`Dropped message, unknown COLLABROOM Data Type ${message.data.type}`);
}
} else if (message.type === 'SWITCH_TO_PAD') {
await handleSwitchToPad(socket, message, authorID);
} else {
messageLogger.warn(`Dropped message, unknown Message Type ${message.type}`);
}
@ -349,37 +372,38 @@ exports.handleCustomMessage = (padID, msgString) => {
* @param message the message from the client
*/
const handleChatMessage = async (socket, message) => {
const time = Date.now();
const text = message.data.text;
const chatMessage = ChatMessage.fromObject(message.data.message);
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
* @param time the timestamp of the chat message
* @param userId the author id of the chat message
* @param text the text of the chat message
* @param padId the padId to send the chat message to
* Adds a new chat message to a pad and sends it to connected clients.
*
* @param {(ChatMessage|number)} mt - Either a chat message object (recommended) or the timestamp of
* the chat message in ms since epoch (deprecated).
* @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) => {
// get the pad
exports.sendChatMessageToPadClients = async (mt, puId, text = null, padId = null) => {
const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt);
padId = mt instanceof ChatMessage ? puId : padId;
const pad = await padManager.getPad(padId);
// get the author
const userName = await authorManager.getAuthorName(userId);
// save the chat message
const promise = pad.appendChatMessage(text, userId, time);
const msg = {
await hooks.aCallAll('chatNewMessage', {message, pad, padId});
// pad.appendChatMessage() ignores the displayName property so we don't need to wait for
// authorManager.getAuthorName() to resolve before saving the message to the database.
const promise = pad.appendChatMessage(message);
message.displayName = await authorManager.getAuthorName(message.userId);
socketio.sockets.in(padId).json.send({
type: 'COLLABROOM',
data: {type: 'CHAT_MESSAGE', userId, userName, time, text},
};
// broadcast the chat message to everyone on the pad
socketio.sockets.in(padId).json.send(msg);
data: {type: 'CHAT_MESSAGE', message},
});
await promise;
};
@ -536,22 +560,6 @@ const handleUserChanges = async (socket, message) => {
// This one's no longer pending, as we're gonna process it now
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
// finish processing the changeset, so keep a reference to the session.
const thisSession = sessioninfos[socket.id];
@ -560,24 +568,20 @@ const handleUserChanges = async (socket, message) => {
// 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 (!thisSession) {
messageLogger.warn('Dropped message, disconnect happened in the mean time');
messageLogger.warn('Ignoring USER_CHANGES from disconnected user');
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
const stopWatch = stats.timer('edits').start();
// get the pad
try {
const {data: {baseRev, apool, changeset}} = message;
if (baseRev == null) throw new Error('missing baseRev');
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);
// create the changeset
try {
try {
// Verify that the changeset has valid syntax and is in canonical form
Changeset.checkRep(changeset);
@ -604,7 +608,7 @@ const handleUserChanges = async (socket, message) => {
op.attribs.split('*').forEach((attr) => {
if (!attr) return;
attr = wireApool.getAttrib(attr);
attr = wireApool.getAttrib(Changeset.parseNum(attr));
if (!attr) return;
// the empty author is used in the clearAuthorship functionality so this
@ -619,16 +623,9 @@ const handleUserChanges = async (socket, message) => {
// 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}`);
}
let rebasedChangeset = Changeset.moveOpsToNewPool(changeset, wireApool, pad.pool);
// ex. applyUserChanges
const apool = pad.pool;
let r = baseRev;
// 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
// and can be applied after "c".
try {
// 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
// of that revision
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");
}
if (baseRev + 1 === r && c === changeset) throw new Error('Changeset already accepted');
changeset = Changeset.follow(c, changeset, false, apool);
} catch (e) {
socket.json.send({disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
throw new Error(`Can't apply USER_CHANGES, because ${e.message}`);
}
rebasedChangeset = Changeset.follow(c, rebasedChangeset, false, pad.pool);
}
const prevText = pad.text();
if (Changeset.oldLen(changeset) !== prevText.length) {
socket.json.send({disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
throw new Error(`Can't apply USER_CHANGES ${changeset} with oldLen ` +
`${Changeset.oldLen(changeset)} to document of length ${prevText.length}`);
if (Changeset.oldLen(rebasedChangeset) !== prevText.length) {
throw new Error(
`Can't apply changeset ${rebasedChangeset} with oldLen ` +
`${Changeset.oldLen(rebasedChangeset)} to document of length ${prevText.length}`);
}
try {
await pad.appendRevision(changeset, thisSession.author);
} catch (e) {
socket.json.send({disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
throw e;
}
await pad.appendRevision(rebasedChangeset, thisSession.author);
const correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool);
if (correctionChangeset) {
@ -692,10 +672,13 @@ const handleUserChanges = async (socket, message) => {
await exports.updatePadClients(pad);
} 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();
}
};
exports.updatePadClients = async (pad) => {
@ -810,58 +793,6 @@ const _correctMarkersInPad = (atext, apool) => {
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
* 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 message the message from the client
*/
const handleClientReady = async (socket, message, authorID) => {
// check if all ok
if (!message.token) {
messageLogger.warn('Dropped message, CLIENT_READY Message has no token!');
return;
const handleClientReady = async (socket, message) => {
const sessionInfo = sessioninfos[socket.id];
// Check if the user has already disconnected.
if (sessionInfo == null) 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;
}
if (!message.padId) {
messageLogger.warn('Dropped message, CLIENT_READY Message has no padId!');
return;
}
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;
await Promise.all([
authorName && authorManager.setAuthorName(sessionInfo.author, authorName),
authorColorId && authorManager.setAuthorColorId(sessionInfo.author, authorColorId),
]);
({colorId: authorColorId, name: authorName} = await authorManager.getAuthor(sessionInfo.author));
// 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)
const authors = pad.getAllAuthors();
@ -914,7 +836,8 @@ const handleClientReady = async (socket, message, authorID) => {
// get all author data out of the database (in parallel)
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) {
messageLogger.error(`There is no author for authorId: ${authorId}. ` +
'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)
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
// Check that the client is still here. It might have disconnected between callbacks.
const sessionInfo = sessioninfos[socket.id];
if (sessionInfo == null) return;
// Check if the user has disconnected during any of the above awaits.
if (sessionInfo !== sessioninfos[socket.id]) return;
// Check if this author is already on the pad, if yes, kick the other sessions!
const roomSockets = _getRoomSockets(pad.id);
for (const socket of roomSockets) {
const sinfo = sessioninfos[socket.id];
if (sinfo && sinfo.author === authorID) {
for (const otherSocket of roomSockets) {
// The user shouldn't have joined the room yet, but check anyway just in case.
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
sessioninfos[socket.id] = {};
socket.leave(padIds.padId);
socket.json.send({disconnect: 'userdup'});
sessioninfos[otherSocket.id] = {};
otherSocket.leave(sessionInfo.padId);
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;
accessLogger.info(`${`[${pad.head > 0 ? 'ENTER' : 'CREATE'}]` +
` pad:${padIds.padId}` +
/* eslint-disable prefer-template -- it doesn't support breaking across multiple lines */
accessLogger.info(`[${pad.head > 0 ? 'ENTER' : 'CREATE'}]` +
` pad:${sessionInfo.padId}` +
` socket:${socket.id}` +
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
` authorID:${authorID}`}${
(user && user.username) ? ` username:${user.username}` : ''}`);
` authorID:${sessionInfo.author}` +
(user && user.username ? ` username:${user.username}` : ''));
/* eslint-enable prefer-template */
if (message.reconnect) {
// If this is a reconnect, we don't have to send the client the ClientVars again
// 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
sessionInfo.rev = message.client_rev;
@ -989,15 +909,14 @@ const handleClientReady = async (socket, message, authorID) => {
changesets[r] = {};
}
// get changesets, author and timestamp needed for pending revisions (in parallel)
const promises = [];
for (const revNum of revisionsNeeded) {
await Promise.all(revisionsNeeded.map(async (revNum) => {
const cs = changesets[revNum];
promises.push(pad.getRevisionChangeset(revNum).then((result) => cs.changeset = result));
promises.push(pad.getRevisionAuthor(revNum).then((result) => cs.author = result));
promises.push(pad.getRevisionDate(revNum).then((result) => cs.timestamp = result));
}
await Promise.all(promises);
[cs.changeset, cs.author, cs.timestamp] = await Promise.all([
pad.getRevisionChangeset(revNum),
pad.getRevisionAuthor(revNum),
pad.getRevisionDate(revNum),
]);
}));
// return pending changesets
for (const r of revisionsNeeded) {
@ -1031,15 +950,14 @@ const handleClientReady = async (socket, message, authorID) => {
apool = attribsForWire.pool.toJsonable();
atext.attribs = attribsForWire.translated;
} catch (e) {
console.error(e.stack || e);
messageLogger.error(e.stack || e);
socket.json.send({disconnect: 'corruptPad'}); // pull the brakes
return;
}
// Warning: never ever send padIds.padId to the client. If the
// client is read only you would open a security hole 1 swedish
// mile wide...
// Warning: never ever send sessionInfo.padId to the client. If the client is read only you
// would open a security hole 1 swedish mile wide...
const clientVars = {
skinName: settings.skinName,
skinVariants: settings.skinVariants,
@ -1054,7 +972,7 @@ const handleClientReady = async (socket, message, authorID) => {
collab_client_vars: {
initialAttributedText: atext,
clientIp: '127.0.0.1',
padId: message.padId,
padId: sessionInfo.auth.padID,
historicalAuthorData,
apool,
rev: pad.getHeadRevisionNumber(),
@ -1063,19 +981,19 @@ const handleClientReady = async (socket, message, authorID) => {
colorPalette: authorManager.getColorPalette(),
clientIp: '127.0.0.1',
userColor: authorColorId,
padId: message.padId,
padId: sessionInfo.auth.padID,
padOptions: settings.padOptions,
padShortcutEnabled: settings.padShortcutEnabled,
initialTitle: `Pad: ${message.padId}`,
initialTitle: `Pad: ${sessionInfo.auth.padID}`,
opts: {},
// 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)
chatHead: pad.chatHead,
numConnectedUsers: roomSockets.length,
readOnlyId: padIds.readOnlyPadId,
readOnlyId: sessionInfo.readOnlyPadId,
readonly: sessionInfo.readonly,
serverTimestamp: Date.now(),
userId: authorID,
userId: sessionInfo.author,
abiwordAvailable: settings.abiwordAvailable(),
sofficeAvailable: settings.sofficeAvailable(),
exportAvailable: settings.exportAvailable(),
@ -1114,7 +1032,7 @@ const handleClientReady = async (socket, message, authorID) => {
}
// Join the pad and start receiving updates
socket.join(padIds.padId);
socket.join(sessionInfo.padId);
// Send the clientVars to the Client
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.
socket.broadcast.to(padIds.padId).json.send({
socket.broadcast.to(sessionInfo.padId).json.send({
type: 'COLLABROOM',
data: {
type: 'USER_NEWINFO',
userInfo: {
colorId: authorColorId,
name: authorName,
userId: authorID,
userId: sessionInfo.author,
},
},
});
@ -1176,6 +1094,15 @@ const handleClientReady = async (socket, message, authorID) => {
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;
socket.json.send({type: 'CHANGESET_REQ', data});
} catch (err) {
console.error(`Error while handling a changeset request for ${padIds.padId}`,
err.toString(), message.data);
messageLogger.error(`Error while handling a changeset request ${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 pad = await padManager.getPad(padId);
const head_revision = pad.getHeadRevisionNumber();
const headRevision = pad.getHeadRevisionNumber();
// calculate the last full endnum
if (endNum > head_revision + 1) {
endNum = head_revision + 1;
}
if (endNum > headRevision + 1) endNum = headRevision + 1;
endNum = Math.floor(endNum / granularity) * granularity;
const compositesChangesetNeeded = [];
@ -1262,39 +1187,22 @@ const getChangesetInfo = async (padId, startNum, endNum, granularity) => {
revTimesNeeded.push(end - 1);
}
// get all needed db values parallel - no await here since
// it would make all the lookups run in series
// get all needed composite Changesets
// Get all needed db values in parallel.
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 p2 = Promise.all(revTimesNeeded.map((revNum) => pad.getRevisionDate(revNum)
.then((revDate) => {
const [lines] = await Promise.all([
getPadLines(padId, startNum - 1),
// 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 all needed revision Dates.
...revTimesNeeded.map(async (revNum) => {
const revDate = await pad.getRevisionDate(revNum);
revisionDate[revNum] = Math.floor(revDate / 1000);
})
));
// get the lines
let lines;
const p3 = getPadLines(padId, startNum - 1).then((_lines) => {
lines = _lines;
});
// wait for all of the above to complete
await Promise.all([p1, p2, p3]);
}),
]);
// doesn't know what happens here exactly :/
const timeDeltas = [];
@ -1304,9 +1212,7 @@ const getChangesetInfo = async (padId, startNum, endNum, granularity) => {
for (let compositeStart = startNum; compositeStart < endNum; compositeStart += granularity) {
const compositeEnd = compositeStart + granularity;
if (compositeEnd > endNum || compositeEnd > head_revision + 1) {
break;
}
if (compositeEnd > endNum || compositeEnd > headRevision + 1) break;
const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`];
const backwards = Changeset.inverse(forwards, lines.textlines, lines.alines, pad.apool());
@ -1391,7 +1297,8 @@ const composePadChangesets = async (padId, startNum, endNum) => {
return changeset;
} catch (e) {
// 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;
}
};

View file

@ -21,9 +21,11 @@
*/
const log4js = require('log4js');
const messageLogger = log4js.getLogger('message');
const settings = require('../utils/Settings');
const stats = require('../stats');
const logger = log4js.getLogger('socket.io');
/**
* Saves all components
* key is the component name
@ -31,53 +33,57 @@ const stats = require('../stats');
*/
const components = {};
let socket;
let io;
/**
* adds a component
*/
exports.addComponent = (moduleName, module) => {
// save the component
if (module == null) return exports.deleteComponent(moduleName);
components[moduleName] = module;
// give the module the socket
module.setSocketIO(socket);
module.setSocketIO(io);
};
exports.deleteComponent = (moduleName) => { delete components[moduleName]; };
/**
* sets the socket.io and adds event functions for routing
*/
exports.setSocketIO = (_socket) => {
// save this socket internaly
socket = _socket;
exports.setSocketIO = (_io) => {
io = _io;
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
client._send = client.send;
client.send = (message) => {
messageLogger.debug(`to ${client.id}: ${JSON.stringify(message)}`);
client._send(message);
socket._send = socket.send;
socket.send = (message) => {
logger.debug(`to ${socket.id}: ${JSON.stringify(message)}`);
socket._send(message);
};
// tell all components about this connect
for (const i of Object.keys(components)) {
components[i].handleConnect(client);
components[i].handleConnect(socket);
}
client.on('message', async (message) => {
if (message.protocolVersion && message.protocolVersion !== 2) {
messageLogger.warn(`Protocolversion header is not correct: ${JSON.stringify(message)}`);
return;
}
socket.on('message', (message, ack = () => {}) => {
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;
}
messageLogger.debug(`from ${client.id}: ${JSON.stringify(message)}`);
await components[message.component].handleMessage(client, message);
logger.debug(`from ${socket.id}: ${JSON.stringify(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
// 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
@ -85,7 +91,7 @@ exports.setSocketIO = (_socket) => {
stats.gauge('lastDisconnect', () => Date.now());
// tell all components about this disconnect
for (const i of Object.keys(components)) {
components[i].handleDisconnect(client);
components[i].handleDisconnect(socket);
}
});
});

View file

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

View file

@ -4,6 +4,7 @@ const log4js = require('log4js');
const clientLogger = log4js.getLogger('client');
const formidable = require('formidable');
const apiHandler = require('../../handler/APIHandler');
const util = require('util');
exports.expressCreateServer = (hookName, args, cb) => {
// The Etherpad client side sends information about how a disconnect happened
@ -14,18 +15,26 @@ exports.expressCreateServer = (hookName, args, cb) => {
});
});
// The Etherpad client side sends information about client side javscript errors
args.app.post('/jserror', (req, res) => {
new formidable.IncomingForm().parse(req, (err, fields, files) => {
let data;
try {
data = JSON.parse(fields.errorInfo);
} catch (e) {
return res.end();
}
clientLogger.warn(`${data.msg} --`, data);
res.end('OK');
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
args.app.post('/jserror', (req, res, next) => {
(async () => {
const data = JSON.parse(await parseJserrorForm(req));
clientLogger.warn(`${data.msg} --`, {
[util.inspect.custom]: (depth, options) => {
// Depth is forced to infinity to ensure that all of the provided data is logged.
options = Object.assign({}, options, {depth: Infinity, colors: true});
return util.inspect(data, options);
},
});
res.end('OK');
})().catch((err) => next(err || new Error(err)));
});
// 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 hooks = require('../../static/js/pluginfw/hooks');
exports.getPadRaw = async (padId) => {
const padKey = `pad:${padId}`;
const padcontent = await db.get(padKey);
exports.getPadRaw = async (padId, readOnlyId) => {
const keyPrefixRead = `pad:${padId}`;
const keyPrefixWrite = readOnlyId ? `pad:${readOnlyId}` : keyPrefixRead;
const padcontent = await db.get(keyPrefixRead);
const records = [padKey];
for (let i = 0; i <= padcontent.head; i++) {
records.push(`${padKey}:revs:${i}`);
}
for (let i = 0; i <= padcontent.chatHead; i++) {
records.push(`${padKey}:chat:${i}`);
}
const keySuffixes = [''];
for (let i = 0; i <= padcontent.head; i++) keySuffixes.push(`:revs:${i}`);
for (let i = 0; i <= padcontent.chatHead; i++) keySuffixes.push(`:chat:${i}`);
const data = {};
for (const key of records) {
// For each piece of info about a pad.
const entry = data[key] = await db.get(key);
for (const keySuffix of keySuffixes) {
const entry = data[keyPrefixWrite + keySuffix] = await db.get(keyPrefixRead + keySuffix);
// Get the Pad Authors
if (entry.pool && entry.pool.numToAttrib) {
@ -50,7 +45,7 @@ exports.getPadRaw = async (padId) => {
if (authorEntry) {
data[`globalAuthor:${authorId}`] = authorEntry;
if (authorEntry.padIDs) {
authorEntry.padIDs = padId;
authorEntry.padIDs = readOnlyId || padId;
}
}
}
@ -63,7 +58,8 @@ exports.getPadRaw = async (padId) => {
const prefixes = await hooks.aCallAll('exportEtherpadAdditionalContent');
await Promise.all(prefixes.map(async (prefix) => {
const key = `${prefix}:${padId}`;
data[key] = await db.get(key);
const writeKey = readOnlyId ? `${prefix}:${readOnlyId}` : key;
data[writeKey] = await db.get(key);
}));
return data;

View file

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

View file

@ -457,7 +457,7 @@ const getHTMLFromAtext = async (pad, atext, authorColors) => {
return pieces.join('');
};
exports.getPadHTMLDocument = async (padId, revNum) => {
exports.getPadHTMLDocument = async (padId, revNum, readOnlyId) => {
const pad = await padManager.getPad(padId);
// 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', {
body: html,
padId: Security.escapeHTML(padId),
padId: Security.escapeHTML(readOnlyId || padId),
extraCSS: stylesForExportCSS,
});
};

View file

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

View file

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

View file

@ -5,56 +5,27 @@
const CleanCSS = require('clean-css');
const Terser = require('terser');
const fsp = require('fs').promises;
const path = require('path');
const Threads = require('threads');
const compressJS = (content) => Terser.minify(content);
const compressCSS = (filename, ROOT_DIR) => new Promise((res, rej) => {
try {
const compressCSS = async (filename, ROOT_DIR) => {
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.
*/
try {
const basePath = path.dirname(absPath);
new CleanCSS({
const output = await new CleanCSS({
rebase: true,
rebaseTo: basePath,
}).minify([absPath], (errors, minified) => {
if (errors) return rej(errors);
return res(minified.styles);
});
}).minify([absPath]);
return output.styles;
} catch (error) {
// on error, just yield the un-minified original, but write a log message
console.error(`Unexpected error minifying ${filename} (${absPath}): ${error}`);
callback(null, content);
return await fsp.readFile(absPath, 'utf8');
}
});
};
Threads.expose({
compressJS,

View file

@ -28,6 +28,7 @@
*/
const absolutePaths = require('./AbsolutePaths');
const deepEqual = require('fast-deep-equal/es6');
const fs = require('fs');
const os = require('os');
const path = require('path');
@ -39,10 +40,39 @@ const suppressDisableMsg = ' -- To suppress these warning messages change ' +
'suppressErrorsInPadText to true in your settings.json\n';
const _ = require('underscore');
const logger = log4js.getLogger('settings');
// Exported values that settings.json and credentials.json cannot override.
const nonSettings = [
'credentialsFilename',
'settingsFilename',
];
// This is a function to make it easy to create a new instance. It is important to not reuse a
// 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 */
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}`);
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
@ -234,7 +264,7 @@ exports.allowUnknownFileEnds = true;
/**
* The log level of log4js
*/
exports.loglevel = 'INFO';
exports.loglevel = defaultLogLevel;
/**
* Disable IP logging
@ -264,7 +294,7 @@ exports.indentationOnNewLine = true;
/*
* log4js appender configuration
*/
exports.logconfig = {appenders: [{type: 'console'}]};
exports.logconfig = defaultLogConfig();
/*
* Session Key, do not sure this.
@ -450,7 +480,7 @@ exports.getGitCommit = () => {
}
version = version.substring(0, 7);
} 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;
};
@ -467,9 +497,14 @@ exports.getEpVersion = () => require('../../package.json').version;
*/
const storeSettings = (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
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
@ -482,7 +517,7 @@ const storeSettings = (settingsObj) => {
}
} else {
// 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,7 +633,7 @@ const lookupEnvironmentVariables = (obj) => {
const defaultValue = match[3];
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. ` +
'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.');
@ -611,7 +646,7 @@ const lookupEnvironmentVariables = (obj) => {
}
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.`);
return coerceValue(defaultValue);
@ -623,7 +658,7 @@ const lookupEnvironmentVariables = (obj) => {
* For numeric and boolean strings let's convert it to proper types before
* returning it, in order to maintain backward compatibility.
*/
console.debug(
logger.debug(
`Configuration key "${key}" will be read from environment variable "${envVarName}"`);
return coerceValue(envVarValue);
@ -650,11 +685,11 @@ const parseSettings = (settingsFilename, isSettings) => {
if (isSettings) {
settingsType = 'settings';
notFoundMessage = 'Continuing using defaults!';
notFoundFunction = console.warn;
notFoundFunction = logger.warn.bind(logger);
} else {
settingsType = 'credentials';
notFoundMessage = 'Ignoring.';
notFoundFunction = console.info;
notFoundFunction = logger.info.bind(logger);
}
try {
@ -672,13 +707,13 @@ const parseSettings = (settingsFilename, isSettings) => {
const settings = JSON.parse(settingsStr);
console.info(`${settingsType} loaded from: ${settingsFilename}`);
logger.info(`${settingsType} loaded from: ${settingsFilename}`);
const replacedSettings = lookupEnvironmentVariables(settings);
return replacedSettings;
} 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}`);
process.exit(1);
@ -686,27 +721,15 @@ const parseSettings = (settingsFilename, isSettings) => {
};
exports.reloadSettings = () => {
// Discover where the settings file lives
const settingsFilename = absolutePaths.makeAbsolute(argv.settings || 'settings.json');
// 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);
const settings = parseSettings(exports.settingsFilename, true);
const credentials = parseSettings(exports.credentialsFilename, false);
storeSettings(settings);
storeSettings(credentials);
log4js.configure(exports.logconfig);// Configure the logging appenders
log4js.setGlobalLogLevel(exports.loglevel);// set loglevel
log4js.replaceConsole();
initLogging(exports.loglevel, exports.logconfig);
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".');
exports.skinName = 'colibris';
}
@ -717,7 +740,7 @@ exports.reloadSettings = () => {
const countPieces = exports.skinName.split(path.sep).length;
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".`);
exports.skinName = 'colibris';
@ -728,7 +751,7 @@ exports.reloadSettings = () => {
// what if someone sets skinName == ".." or "."? We catch him!
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".');
exports.skinName = 'colibris';
@ -736,13 +759,12 @@ exports.reloadSettings = () => {
}
if (fs.existsSync(skinPath) === false) {
console.error(
`Skin path ${skinPath} does not exist. Falling back to the default "colibris".`);
logger.error(`Skin path ${skinPath} does not exist. Falling back to the default "colibris".`);
exports.skinName = 'colibris';
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) {
@ -754,7 +776,7 @@ exports.reloadSettings = () => {
if (!exports.suppressErrorsInPadText) {
exports.defaultPadText += `\nError: ${abiwordError}${suppressDisableMsg}`;
}
console.error(`${abiwordError} File location: ${exports.abiword}`);
logger.error(`${abiwordError} File location: ${exports.abiword}`);
exports.abiword = null;
}
});
@ -770,7 +792,7 @@ exports.reloadSettings = () => {
if (!exports.suppressErrorsInPadText) {
exports.defaultPadText += `\nError: ${sofficeError}${suppressDisableMsg}`;
}
console.error(`${sofficeError} File location: ${exports.soffice}`);
logger.error(`${sofficeError} File location: ${exports.soffice}`);
exports.soffice = null;
}
});
@ -780,15 +802,15 @@ exports.reloadSettings = () => {
const sessionkeyFilename = absolutePaths.makeAbsolute(argv.sessionkey || './SESSIONKEY.txt');
try {
exports.sessionKey = fs.readFileSync(sessionkeyFilename, 'utf8');
console.info(`Session key loaded from: ${sessionkeyFilename}`);
logger.info(`Session key loaded from: ${sessionkeyFilename}`);
} catch (e) {
console.info(
logger.info(
`Session key file "${sessionkeyFilename}" not found. Creating with random contents.`);
exports.sessionKey = randomString(32);
fs.writeFileSync(sessionkeyFilename, exports.sessionKey, 'utf8');
}
} 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. -- ' +
'If you are seeing this error after restarting using the Admin User ' +
'Interface then you can ignore this message.');
@ -801,12 +823,12 @@ exports.reloadSettings = () => {
}
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 === '') {
// 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.');
}
@ -822,7 +844,7 @@ exports.reloadSettings = () => {
* TODO: remove the "?v=randomstring" parameter, and replace with hashed filenames instead
*/
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 = {

View file

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

View file

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

3353
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -92,12 +92,18 @@
#chattext p {
padding: 3px;
overflow-x: hidden;
white-space: pre-wrap;
word-wrap: break-word;
}
#chattext .time {
float: right;
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;
margin-left: 3px;
margin-right: 2px;
@ -109,6 +115,7 @@
}
#chatinputbox #chatinput {
width: 100%;
resize: vertical;
}

View file

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

View file

@ -45,9 +45,6 @@
.popup input[type=text], #users input[type=text] {
outline: none;
}
.popup a {
text-decoration: none
}
.popup h1 {
font-size: 1.8rem;
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;
if (lineLength - markerWidth < 0) throw new Error(`line ${row} has negative length`);
const startCol = row === start[0] ? start[1] : markerWidth;
if (startCol - markerWidth < 0) throw new RangeError('selection starts before line start');
if (start[1] < 0) throw new RangeError('selection starts at negative column');
const startCol = Math.max(markerWidth, row === start[0] ? start[1] : 0);
if (startCol > lineLength) throw new RangeError('selection starts after line end');
const endCol = row === end[0] ? end[1] : lineLength;
if (endCol - markerWidth < 0) throw new RangeError('selection ends before line start');
if (end[1] < 0) throw new RangeError('selection ends at negative column');
const endCol = Math.max(markerWidth, row === end[0] ? end[1] : lineLength);
if (endCol > lineLength) throw new RangeError('selection ends after line end');
if (startCol > endCol) throw new RangeError('selection ends before it starts');

View file

@ -23,19 +23,84 @@
* limitations under the License.
*/
/*
An AttributePool maintains a mapping from [key,value] Pairs called
Attributes to Numbers (unsigened integers) and vice versa. These numbers are
used to reference Attributes in Changesets.
*/
/**
* A `[key, value]` pair of strings describing a text attribute.
*
* @typedef {[string, string]} Attribute
*/
const AttributePool = function () {
/**
* Maps an attribute's identifier to the attribute.
*
* @typedef {Object.<number, Attribute>} NumToAttrib
*/
/**
* An intermediate representation of the contents of an attribute pool, suitable for serialization
* via `JSON.stringify` and transmission to another user.
*
* @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']}
this.attribToNum = {}; // e.g. {'foo,bar': 0}
this.nextNum = 0;
};
AttributePool.prototype.putAttrib = function (attrib, dontAddIfAbsent) {
/**
* 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;
}
/**
* 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];
@ -47,43 +112,74 @@ AttributePool.prototype.putAttrib = function (attrib, dontAddIfAbsent) {
this.attribToNum[str] = num;
this.numToAttrib[num] = [String(attrib[0] || ''), String(attrib[1] || '')];
return num;
};
}
AttributePool.prototype.getAttrib = function (num) {
/**
* @param {number} num - The identifier of the attribute to fetch.
* @returns {Attribute} The attribute with the given identifier, or nullish if there is no such
* attribute.
*/
getAttrib(num) {
const pair = this.numToAttrib[num];
if (!pair) {
return pair;
}
return [pair[0], pair[1]]; // return a mutable copy
};
}
AttributePool.prototype.getAttribKey = function (num) {
/**
* @param {number} num - The identifier of the attribute to fetch.
* @returns {string} Eqivalent to `getAttrib(num)[0]` if the attribute exists, otherwise the empty
* string.
*/
getAttribKey(num) {
const pair = this.numToAttrib[num];
if (!pair) return '';
return pair[0];
};
}
AttributePool.prototype.getAttribValue = function (num) {
/**
* @param {number} num - The identifier of the attribute to fetch.
* @returns {string} Eqivalent to `getAttrib(num)[1]` if the attribute exists, otherwise the empty
* string.
*/
getAttribValue(num) {
const pair = this.numToAttrib[num];
if (!pair) return '';
return pair[1];
};
}
AttributePool.prototype.eachAttrib = function (func) {
/**
* 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]);
}
};
}
AttributePool.prototype.toJsonable = function () {
/**
* @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,
};
};
}
AttributePool.prototype.fromJsonable = function (obj) {
/**
* 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 = {};
@ -91,7 +187,7 @@ AttributePool.prototype.fromJsonable = function (obj) {
this.attribToNum[String(this.numToAttrib[n])] = Number(n);
}
return this;
};
}
}
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',
'focus',
'setEditable',
'getFormattedCode',
'setOnKeyPress',
'setOnKeyDown',
'setNotifyDirty',
@ -121,7 +120,6 @@ const Ace2Editor = function () {
'applyPreparedChangesetToBase',
'setUserChangeNotificationCallback',
'setAuthorInfo',
'setAuthorSelectionRange',
'callWithAce',
'execCommand',
'replaceRange',
@ -138,8 +136,6 @@ const Ace2Editor = function () {
this.exportText = () => loaded ? info.ace_exportText() : '(awaiting init)\n';
this.getDebugProperty = (prop) => info.ace_getDebugProperty(prop);
this.getInInternationalComposition =
() => loaded ? info.ace_getInInternationalComposition() : null;
@ -153,9 +149,6 @@ const Ace2Editor = function () {
// changes, and modify the changeset to be applied by applyPreparedChangesetToBase accordingly.
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) => {
for (const file of files) {
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
// readyState becomes 'complete' even though it never fires a readystatechange event.
// - 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;
document.getElementById(containerId).appendChild(outerFrame);
const outerWindow = outerFrame.contentWindow;
@ -228,6 +223,10 @@ const Ace2Editor = function () {
sideDiv.id = 'sidediv';
sideDiv.classList.add('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');
lineMetricsDiv.id = 'linemetricsdiv';
lineMetricsDiv.appendChild(outerDocument.createTextNode('x'));
@ -241,8 +240,7 @@ const Ace2Editor = function () {
innerFrame.allowTransparency = true; // for IE
// The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above
// outerFrame.srcdoc.
innerFrame.srcdoc = '<!DOCTYPE html>';
innerFrame.ace_outerWin = outerWindow;
innerFrame.src = 'empty.html';
outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild);
const innerWindow = innerFrame.contentWindow;
@ -284,7 +282,6 @@ const Ace2Editor = function () {
// <body> tag
innerDocument.body.id = 'innerdocbody';
innerDocument.body.classList.add('innerdocbody');
innerDocument.body.setAttribute('role', 'application');
innerDocument.body.setAttribute('spellcheck', 'false');
innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); // &nbsp;

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,7 @@
'use strict';
/* global socketio */
$(document).ready(() => {
const socket = socketio.connect('..', '/settings');
const socket = window.socketio.connect('..', '/settings');
socket.on('connect', () => {
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
}
});
// deal with someone is the author of a line and changes one character,
// so the alines won't change
// some chars are replaced (no attributes change and no length change)
// test if there are keep ops at the start of the cs
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) => {

View file

@ -136,15 +136,6 @@ const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
// that includes old submittedChangeset
toSubmit = Changeset.compose(submittedChangeset, userChangeset, apool);
} 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
const authorId = parent.parent.pad.myUserInfo.userId;

View file

@ -15,12 +15,16 @@
* limitations under the License.
*/
const ChatMessage = require('./ChatMessage');
const padutils = require('./pad_utils').padutils;
const padcookie = require('./pad_cookie').padcookie;
const Tinycon = require('tinycon/tinycon');
const hooks = require('./pluginfw/hooks');
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 = (() => {
let isStuck = false;
let userAndChat = false;
@ -99,25 +103,28 @@ exports.chat = (() => {
}
}
},
send() {
async send() {
const text = $('#chatinput').val();
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('');
},
addMessage(msg, increment, isHistoryAdd) {
async addMessage(msg, increment, isHistoryAdd) {
msg = ChatMessage.fromObject(msg);
// correct the time
msg.time += this._pad.clientTimeOffset;
if (!msg.userId) {
if (!msg.authorId) {
/*
* 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".
*/
msg.userId = 'unknown';
msg.authorId = 'unknown';
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.');
}
@ -128,9 +135,11 @@ exports.chat = (() => {
// the hook args
const ctx = {
authorName: msg.userName != null ? msg.userName : html10n.get('pad.userlist.unnamed'),
author: msg.userId,
authorName: msg.displayName != null ? msg.displayName : html10n.get('pad.userlist.unnamed'),
author: msg.authorId,
text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'),
message: msg,
rendered: null,
sticky: false,
timestamp: msg.time,
timeStr: (() => {
@ -149,10 +158,11 @@ exports.chat = (() => {
// does the user already have the chatbox open?
const chatOpen = $('#chatbox').hasClass('visible');
// does this message contain this user's name? (is the curretn user mentioned?)
const myName = $('#myusernameedit').val();
// does this message contain this user's name? (is the current user mentioned?)
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 (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) {
@ -161,10 +171,9 @@ exports.chat = (() => {
ctx.sticky = true;
}
// Call chat message hook
hooks.aCallAll('chatNewMessage', ctx, () => {
await hooks.aCallAll('chatNewMessage', ctx);
const cls = authorClass(ctx.author);
const chatMsg = $('<p>')
const chatMsg = ctx.rendered != null ? $(ctx.rendered) : $('<p>')
.attr('data-authorId', ctx.author)
.addClass(cls)
.append($('<b>').text(`${ctx.authorName}:`))
@ -180,6 +189,7 @@ exports.chat = (() => {
.append($('<div>').html(ctx.text).contents());
if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton');
else $('#chattext').append(chatMsg);
chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e));
// should we increment the counter??
if (increment && !isHistoryAdd) {
@ -189,26 +199,21 @@ exports.chat = (() => {
$('#chatcounter').text(count);
if (!chatOpen && ctx.duration > 0) {
$.gritter.add({
text: $('<p>')
const text = $('<p>')
.append($('<span>').addClass('author-name').text(ctx.authorName))
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted
// to not introduce an XSS vulnerability by adding unescaped user input.
.append($('<div>').html(ctx.text).contents()),
.append($('<div>').html(ctx.text).contents());
text.each((i, e) => html10n.translateElement(html10n.translations, e));
$.gritter.add({
text,
sticky: ctx.sticky,
time: 5000,
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();
},
init(pad) {
@ -224,6 +229,11 @@ exports.chat = (() => {
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;
$('body:not(#chatinput)').on('keypress', function (evt) {
@ -238,7 +248,7 @@ exports.chat = (() => {
$('#chatinput').keypress((evt) => {
// if the user typed enter, fire the send
if (evt.which === 13 || evt.which === 10) {
if (evt.key === 'Enter' && !evt.shiftKey) {
evt.preventDefault();
this.send();
}

View file

@ -245,14 +245,6 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
} else if (msg.type === 'USER_NEWINFO') {
const userInfo = msg.userInfo;
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]) {
userSet[id] = userInfo;
callbacks.onUpdateUserInfo(userInfo);
@ -272,7 +264,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
} else if (msg.type === 'CLIENT_MESSAGE') {
callbacks.onClientMessage(msg.payload);
} else if (msg.type === 'CHAT_MESSAGE') {
chat.addMessage(msg, true, false);
chat.addMessage(msg.message, true, false);
} else if (msg.type === 'CHAT_MESSAGES') {
for (let i = msg.messages.length - 1; i >= 0; i--) {
chat.addMessage(msg.messages[i], true, true);

View file

@ -31,30 +31,7 @@ const Changeset = require('./Changeset');
const hooks = require('./pluginfw/hooks');
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();
// .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
const supportedElems = new Set([
'author',
@ -115,9 +92,8 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
attribsBuilder = Changeset.smartOpAssembler();
},
textOfLine: (i) => textArray[i],
appendText: (txt, attrString) => {
appendText: (txt, attrString = '') => {
textArray[textArray.length - 1] += txt;
// dmesg(txt+" / "+attrString);
op.attribs = attrString;
op.chars = txt.length;
attribsBuilder.append(op);
@ -147,17 +123,13 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
let selEnd = [-1, -1];
const _isEmpty = (node, state) => {
// consider clean blank lines pasted in IE to be empty
if (childNodes(node).length === 0) return true;
if (childNodes(node).length === 1 &&
if (node.childNodes.length === 0) return true;
if (node.childNodes.length === 1 &&
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;' &&
!getAssoc(node, 'unpasted')) {
if (state) {
const child = childNodes(node)[0];
const child = node.childNodes[0];
_reachPoint(child, 0, state);
_reachPoint(child, 1, state);
}
@ -177,7 +149,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
};
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) => {
@ -349,8 +321,8 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
const startLine = lines.length() - 1;
_reachBlockPoint(node, 0, state);
if (isTextNode(node)) {
const tname = getAttribute(node.parentNode, 'name');
if (node.nodeType === node.TEXT_NODE) {
const tname = node.parentNode.getAttribute('name');
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
// 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);
}
}
} else if (isElementNode(node)) {
} else if (node.nodeType === node.ELEMENT_NODE) {
const tname = tagName(node) || '';
if (tname === 'img') {
@ -426,7 +398,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
if (tname === 'br') {
this.breakLine = true;
const tvalue = getAttribute(node, 'value');
const tvalue = node.getAttribute('value');
const [startNewLine = true] = hooks.callAll('collectContentLineBreak', {
cc: this,
state,
@ -441,8 +413,8 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
} else if (tname === 'script' || tname === 'style') {
// ignore
} else if (!isEmpty) {
let styl = getAttribute(node, 'style');
let cls = getAttribute(node, 'class');
let styl = node.getAttribute('style');
let cls = node.getAttribute('class');
let isPre = (tname === 'pre');
if ((!isPre) && abrowser && abrowser.safari) {
isPre = (styl && /\bwhite-space:\s*pre\b/i.exec(styl));
@ -489,14 +461,14 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
cc.doAttrib(state, 'strikethrough');
}
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);
// 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
if (!rr && !type) {
for (const child of childNodes(node)) {
for (const child of node.childNodes) {
if (tagName(child) !== 'ul') continue;
type = getAttribute(child, 'class');
type = child.getAttribute('class');
if (type) break;
}
}
@ -504,7 +476,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
type = rr[1];
} else {
if (tname === 'ul') {
const cls = getAttribute(node, 'class');
const cls = node.getAttribute('class');
if ((type && type.match('indent')) || (cls && cls.match('indent'))) {
type = 'indent';
} 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);
}

View file

@ -61,6 +61,8 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
if (document) {
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 {
result.node = {
innerHTML: '',
@ -224,7 +226,6 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
};
result.prepareForAdd = writeHTML;
result.finishUpdate = writeHTML;
result.getInnerHTML = () => curHTML || '';
return result;
};

View file

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

View file

@ -48,8 +48,6 @@ const socketio = require('./socketio');
const hooks = require('./pluginfw/hooks');
let receivedClientVars = false;
// This array represents all GET-parameters which can be used to change a setting.
// name: the parameter-name, eg `?noColors=true` => `noColors`
// checkVal: the callback is only executed when
@ -159,30 +157,17 @@ const getParams = () => {
// Then URL applied stuff
const params = getUrlVars();
for (const setting of getParameters) {
const value = params[setting.name];
const value = params.get(setting.name);
if (value && (value === setting.checkVal || setting.checkVal == null)) {
setting.callback(value);
}
}
};
const getUrlVars = () => {
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 getUrlVars = () => new URL(window.location.href).searchParams;
const sendClientReady = (isReconnect, messageType) => {
messageType = typeof messageType !== 'undefined' ? messageType : 'CLIENT_READY';
const sendClientReady = (isReconnect) => {
let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1);
// unescape neccesary due to Safari and Opera interpretation of spaces
padId = decodeURIComponent(padId);
@ -199,13 +184,23 @@ const sendClientReady = (isReconnect, messageType) => {
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 = {
component: 'pad',
type: messageType,
type: 'CLIENT_READY',
padId,
sessionID: Cookies.get('sessionID'),
token,
protocolVersion: 2,
userInfo,
};
// this is a reconnect, lets tell the server our revisionnumber
@ -217,7 +212,8 @@ const sendClientReady = (isReconnect, messageType) => {
socket.json.send(msg);
};
const handshake = () => {
const handshake = async () => {
let receivedClientVars = false;
let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1);
// unescape neccesary due to Safari and Opera interpretation of spaces
padId = decodeURIComponent(padId);
@ -297,63 +293,8 @@ const handshake = () => {
}
}
} else if (!receivedClientVars && obj.type === 'CLIENT_VARS') {
// if we haven't recieved the clientVars yet, then this message should it be
receivedClientVars = true;
// set some client vars
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) {
padconnectionstatus.disconnected(obj.disconnect);
socket.disconnect();
@ -365,17 +306,49 @@ const handshake = () => {
return;
} else {
pad.collabClient.handleMessageFromServer(obj);
pad._messageQ.enqueue(obj);
}
});
// Bind the colorpicker
$('#colorpicker').farbtastic({callback: '#mycolorpickerpreview', width: 220});
// Bind the read only button
$('#readonlyinput').on('click', () => {
padeditbar.setEmbedLinks();
});
await Promise.all([
new Promise((resolve) => {
const h = (obj) => {
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 = {
// don't access these directly from outside this file, except
// for debugging
@ -385,65 +358,34 @@ const pad = {
initTime: 0,
clientTimeOffset: null,
padOptions: {},
_messageQ: new MessageQueue(),
// these don't require init; clientVars should all go through here
getPadId: () => clientVars.padId,
getClientIp: () => clientVars.clientIp,
getColorPalette: () => clientVars.colorPalette,
getIsDebugEnabled: () => clientVars.debugEnabled,
getPrivilege: (name) => clientVars.accountPrivs[name],
getUserId: () => pad.myUserInfo.userId,
getUserName: () => pad.myUserInfo.name,
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) => {
pad.collabClient.sendClientMessage(msg);
},
init: () => {
init() {
padutils.setupGlobalExceptionHandler();
$(document).ready(() => {
// start the custom js
if (typeof customStart === 'function') customStart(); // eslint-disable-line no-undef
handshake();
// To use etherpad you have to allow cookies.
// This will check if the prefs-cookie is set.
// Otherwise it shows up a message to the user.
// $(handler), $().ready(handler), $.wait($.ready).then(handler), etc. don't work if handler is
// an async function for some bizarre reason, so the async function is wrapped in a non-async
// function.
$(() => (async () => {
if (window.customStart != null) window.customStart();
$('#colorpicker').farbtastic({callback: '#mycolorpickerpreview', width: 220});
$('#readonlyinput').on('click', () => { padeditbar.setEmbedLinks(); });
padcookie.init();
});
await handshake();
this._afterHandshake();
})());
},
_afterHandshake() {
pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp;
@ -502,7 +444,7 @@ const pad = {
$('#editorcontainer').addClass('initialized');
hooks.aCallAll('postAceInit', {ace: padeditor.ace, pad});
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
};
// order of inits is important here:
@ -516,6 +458,7 @@ const pad = {
pad.collabClient = getCollabClient(
padeditor.ace, clientVars.collab_client_vars, pad.myUserInfo,
{colorPalette: pad.getColorPalette()}, pad);
this._messageQ.setCollabClient(this.collabClient);
pad.collabClient.setOnUserJoin(pad.handleUserJoin);
pad.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate);
pad.collabClient.setOnUserLeave(pad.handleUserLeave);
@ -532,7 +475,57 @@ const pad = {
// there are no messages
$('#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: () => {
padeditor.dispose();
},
@ -610,16 +603,6 @@ const pad = {
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) => {
const oldFullyConnected = !!padconnectionstatus.isFullyConnected();
const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting');
@ -760,7 +743,5 @@ exports.baseURL = '';
exports.settings = settings;
exports.randomString = randomString;
exports.getParams = getParams;
exports.getUrlVars = getUrlVars;
exports.handshake = handshake;
exports.pad = pad;
exports.init = init;

View file

@ -30,58 +30,55 @@ const padsavedrevs = require('./pad_savedrevs');
const _ = require('underscore');
require('./vendors/nice-select');
const ToolbarItem = function (element) {
class ToolbarItem {
constructor(element) {
this.$el = element;
};
}
ToolbarItem.prototype.getCommand = function () {
getCommand() {
return this.$el.attr('data-key');
};
}
ToolbarItem.prototype.getValue = function () {
getValue() {
if (this.isSelect()) {
return this.$el.find('select').val();
}
};
}
ToolbarItem.prototype.setValue = function (val) {
setValue(val) {
if (this.isSelect()) {
return this.$el.find('select').val(val);
}
};
}
ToolbarItem.prototype.getType = function () {
getType() {
return this.$el.attr('data-type');
};
}
ToolbarItem.prototype.isSelect = function () {
isSelect() {
return this.getType() === 'select';
};
}
ToolbarItem.prototype.isButton = function () {
isButton() {
return this.getType() === 'button';
};
}
ToolbarItem.prototype.bind = function (callback) {
const self = this;
if (self.isButton()) {
self.$el.click((event) => {
bind(callback) {
if (this.isButton()) {
this.$el.click((event) => {
$(':focus').blur();
callback(self.getCommand(), self);
callback(this.getCommand(), this);
event.preventDefault();
});
} else if (self.isSelect()) {
self.$el.find('select').change(() => {
callback(self.getCommand(), self);
} else if (this.isSelect()) {
this.$el.find('select').change(() => {
callback(this.getCommand(), this);
});
}
};
}
}
const padeditbar = (function () {
const syncAnimationFn = () => {
const syncAnimation = (() => {
const SYNCING = -100;
const DONE = 100;
let state = DONE;
@ -124,37 +121,39 @@ const padeditbar = (function () {
animator.scheduleAnimation();
},
};
};
const syncAnimation = syncAnimationFn();
})();
exports.padeditbar = new class {
constructor() {
this._editbarPosition = 0;
this.commands = {};
this.dropdowns = [];
}
const self = {
init() {
const self = this;
self.dropdowns = [];
$('#editbar .editbarbutton').attr('unselectable', 'on'); // for IE
this.enable();
$('#editbar [data-key]').each(function () {
$(this).unbind('click');
(new ToolbarItem($(this))).bind((command, item) => {
self.triggerCommand(command, item);
$('#editbar [data-key]').each((i, elt) => {
$(elt).unbind('click');
new ToolbarItem($(elt)).bind((command, item) => {
this.triggerCommand(command, item);
});
});
$('body:not(#editorcontainerbox)').on('keydown', (evt) => {
bodyKeyEvent(evt);
this._bodyKeyEvent(evt);
});
$('.show-more-icon-btn').click(() => {
$('.toolbar').toggleClass('full-icons');
});
self.checkAllIconsAreDisplayedInToolbar();
$(window).resize(_.debounce(self.checkAllIconsAreDisplayedInToolbar, 100));
this.checkAllIconsAreDisplayedInToolbar();
$(window).resize(_.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
registerDefaultCommands(self);
this._registerDefaultCommands();
hooks.callAll('postToolbarInit', {
toolbar: self,
toolbar: this,
ace: padeditor.ace,
});
@ -169,43 +168,46 @@ const padeditbar = (function () {
}
// When editor is scrolled, we add a class to style the editbar differently
$('iframe[name="ace_outer"]').contents().scroll(function () {
$('#editbar').toggleClass('editor-scrolled', $(this).scrollTop() > 2);
$('iframe[name="ace_outer"]').contents().scroll((ev) => {
$('#editbar').toggleClass('editor-scrolled', $(ev.currentTarget).scrollTop() > 2);
});
},
isEnabled: () => true,
disable: () => {
}
isEnabled() { return true; }
disable() {
$('#editbar').addClass('disabledtoolbar').removeClass('enabledtoolbar');
},
enable: () => {
}
enable() {
$('#editbar').addClass('enabledtoolbar').removeClass('disabledtoolbar');
},
commands: {},
}
registerCommand(cmd, callback) {
this.commands[cmd] = callback;
return this;
},
}
registerDropdownCommand(cmd, dropdown) {
dropdown = dropdown || cmd;
self.dropdowns.push(dropdown);
this.dropdowns.push(dropdown);
this.registerCommand(cmd, () => {
self.toggleDropDown(dropdown);
this.toggleDropDown(dropdown);
});
},
}
registerAceCommand(cmd, callback) {
this.registerCommand(cmd, (cmd, ace, item) => {
ace.callWithAce((ace) => {
callback(cmd, ace, item);
}, cmd, true);
});
},
}
triggerCommand(cmd, item) {
if (self.isEnabled() && this.commands[cmd]) {
if (this.isEnabled() && this.commands[cmd]) {
this.commands[cmd](cmd, padeditor.ace, item);
}
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
if (moduleName === 'users' && $('#users').hasClass('stickyUsers')) {
return;
@ -216,10 +218,7 @@ const padeditbar = (function () {
// hide all modules and remove highlighting of all buttons
if (moduleName === 'none') {
const returned = false;
for (let i = 0; i < self.dropdowns.length; i++) {
const thisModuleName = self.dropdowns[i];
for (const thisModuleName of this.dropdowns) {
// skip the userlist
if (thisModuleName === 'users') continue;
@ -233,13 +232,10 @@ const padeditbar = (function () {
module.removeClass('popup-show');
}
}
if (!returned && cb) return cb();
} else {
// hide all modules that are not selected and remove highlighting
// respectively add highlighting to the corresponding button
for (let i = 0; i < self.dropdowns.length; i++) {
const thisModuleName = self.dropdowns[i];
for (const thisModuleName of this.dropdowns) {
const module = $(`#${thisModuleName}`);
if (module.hasClass('popup-show')) {
@ -248,21 +244,23 @@ const padeditbar = (function () {
} else if (thisModuleName === moduleName) {
$(`li[data-key=${thisModuleName}] > a`).addClass('selected');
module.addClass('popup-show');
if (cb) {
cb();
}
}
}
} catch (err) {
cbErr = err || new Error(err);
} finally {
if (cb) Promise.resolve().then(() => cb(cbErr));
}
},
setSyncStatus: (status) => {
}
setSyncStatus(status) {
if (status === 'syncing') {
syncAnimation.syncing();
} else if (status === 'done') {
syncAnimation.done();
}
},
setEmbedLinks: () => {
}
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"';
@ -279,46 +277,41 @@ const padeditbar = (function () {
.val(`<iframe name="embed_readwrite" src="${padUrl}${params}" ${props}></iframe>`);
$('#linkinput').val(padUrl);
}
},
checkAllIconsAreDisplayedInToolbar: () => {
}
checkAllIconsAreDisplayedInToolbar() {
// reset style
$('.toolbar').removeClass('cropped');
$('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
// Layout it takes the full width on the bottom of the page
const menuRightWidth = 280;
if (menu_left && menu_left.scrollWidth > $('.toolbar').width() - menuRightWidth ||
if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width() - menuRightWidth ||
$('.toolbar').width() < 1000) {
$('body').addClass('mobile-layout');
}
if (menu_left && menu_left.scrollWidth > $('.toolbar').width()) {
if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width()) {
$('.toolbar').addClass('cropped');
}
},
};
}
let editbarPosition = 0;
const bodyKeyEvent = (evt) => {
_bodyKeyEvent(evt) {
// If the event is Alt F9 or Escape & we're already in the editbar menu
// Send the users focus back to the pad
if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) {
if ($(':focus').parents('.toolbar').length === 1) {
// If we're in the editbar already..
// 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
// Or some other window I haven't thought about!
if (typeof pad === 'undefined') {
// 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
} 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
// The above focus doesn't always work in FF, you have to hit enter afterwards
evt.preventDefault();
@ -327,7 +320,7 @@ const padeditbar = (function () {
// Focus on the editbar :)
const firstEditbarElement = parent.parent.$('#editbar button').first();
$(this).blur();
$(evt.currentTarget).blur();
firstEditbarElement.focus();
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 ($('.popup').is(':visible') || evt.target.localName === 'input') return;
editbarPosition--;
this._editbarPosition--;
// Allow focus to shift back to end of row and start of row
if (editbarPosition === -1) editbarPosition = focusItems.length - 1;
$(focusItems[editbarPosition]).focus();
if (this._editbarPosition === -1) this._editbarPosition = focusItems.length - 1;
$(focusItems[this._editbarPosition]).focus();
}
// On right arrow move to next button in editbar
@ -356,33 +349,28 @@ const padeditbar = (function () {
// 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;
editbarPosition++;
this._editbarPosition++;
// Allow focus to shift back to end of row and start of row
if (editbarPosition >= focusItems.length) editbarPosition = 0;
$(focusItems[editbarPosition]).focus();
if (this._editbarPosition >= focusItems.length) this._editbarPosition = 0;
$(focusItems[this._editbarPosition]).focus();
}
}
}
};
const aceAttributeCommand = (cmd, ace) => {
ace.ace_toggleAttributeOnSelection(cmd);
};
_registerDefaultCommands() {
this.registerDropdownCommand('showusers', 'users');
this.registerDropdownCommand('settings');
this.registerDropdownCommand('connectivity');
this.registerDropdownCommand('import_export');
this.registerDropdownCommand('embed');
const registerDefaultCommands = (toolbar) => {
toolbar.registerDropdownCommand('showusers', 'users');
toolbar.registerDropdownCommand('settings');
toolbar.registerDropdownCommand('connectivity');
toolbar.registerDropdownCommand('import_export');
toolbar.registerDropdownCommand('embed');
toolbar.registerCommand('settings', () => {
toolbar.toggleDropDown('settings', () => {
this.registerCommand('settings', () => {
this.toggleDropDown('settings');
$('#options-stickychat').focus();
});
});
toolbar.registerCommand('import_export', () => {
toolbar.toggleDropDown('import_export', () => {
this.registerCommand('import_export', () => {
this.toggleDropDown('import_export');
// If Import file input exists then focus on it..
if ($('#importfileinput').length !== 0) {
setTimeout(() => {
@ -392,61 +380,61 @@ const padeditbar = (function () {
$('.exportlink').first().focus();
}
});
});
toolbar.registerCommand('showusers', () => {
toolbar.toggleDropDown('users', () => {
this.registerCommand('showusers', () => {
this.toggleDropDown('users');
$('#myusernameedit').focus();
});
});
toolbar.registerCommand('embed', () => {
toolbar.setEmbedLinks();
toolbar.toggleDropDown('embed', () => {
this.registerCommand('embed', () => {
this.setEmbedLinks();
this.toggleDropDown('embed');
$('#linkinput').focus().select();
});
});
toolbar.registerCommand('savedRevision', () => {
this.registerCommand('savedRevision', () => {
padsavedrevs.saveNow();
});
toolbar.registerCommand('showTimeSlider', () => {
this.registerCommand('showTimeSlider', () => {
document.location = `${document.location.pathname}/timeslider`;
});
toolbar.registerAceCommand('bold', aceAttributeCommand);
toolbar.registerAceCommand('italic', aceAttributeCommand);
toolbar.registerAceCommand('underline', aceAttributeCommand);
toolbar.registerAceCommand('strikethrough', aceAttributeCommand);
const aceAttributeCommand = (cmd, ace) => {
ace.ace_toggleAttributeOnSelection(cmd);
};
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);
});
toolbar.registerAceCommand('redo', (cmd, ace) => {
this.registerAceCommand('redo', (cmd, ace) => {
ace.ace_doUndoRedo(cmd);
});
toolbar.registerAceCommand('insertunorderedlist', (cmd, ace) => {
this.registerAceCommand('insertunorderedlist', (cmd, ace) => {
ace.ace_doInsertUnorderedList();
});
toolbar.registerAceCommand('insertorderedlist', (cmd, ace) => {
this.registerAceCommand('insertorderedlist', (cmd, ace) => {
ace.ace_doInsertOrderedList();
});
toolbar.registerAceCommand('indent', (cmd, ace) => {
this.registerAceCommand('indent', (cmd, ace) => {
if (!ace.ace_doIndentOutdent(false)) {
ace.ace_doInsertUnorderedList();
}
});
toolbar.registerAceCommand('outdent', (cmd, ace) => {
this.registerAceCommand('outdent', (cmd, ace) => {
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
const rep = ace.ace_getRep();
let doPrompt = false;
@ -479,7 +467,7 @@ const padeditbar = (function () {
}
});
toolbar.registerCommand('timeslider_returnToPad', (cmd) => {
this.registerCommand('timeslider_returnToPad', (cmd) => {
if (document.referrer.length > 0 &&
document.referrer.substring(document.referrer.lastIndexOf('/') - 1,
document.referrer.lastIndexOf('/')) === 'p') {
@ -489,9 +477,5 @@ const padeditbar = (function () {
.substring(0, document.location.href.lastIndexOf('/'));
}
});
};
return self;
}());
exports.padeditbar = padeditbar;
}
}();

View file

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

View file

@ -67,7 +67,7 @@ const padimpexp = (() => {
importErrorMessage(message);
} else {
$('#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'));
window.setTimeout(() => $('#importfileinput').removeAttr('disabled'), 0);

View file

@ -32,7 +32,7 @@ const padmodals = (() => {
pad = _pad;
},
showModal: (messageId) => {
padeditbar.toggleDropDown('none', () => {
padeditbar.toggleDropDown('none');
$('#connectivity .visible').removeClass('visible');
$(`#connectivity .${messageId}`).addClass('visible');
@ -40,7 +40,6 @@ const padmodals = (() => {
automaticReconnect.showCountDownTimerToReconnectOnModal($modal, pad);
padeditbar.toggleDropDown('connectivity');
});
},
showOverlay: () => {
// 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 = (() => {
// 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.)
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
// character that matches urlChar.
const postUrlPunct = '[:.,;?!)\'*]';
const postUrlPunct = '[:.,;?!)\\]\'*]';
// Schemes that must be followed by ://
const withAuth = `(?:${[
'(?:x-)?man',
@ -89,6 +89,25 @@ const urlRegex = (() => {
})();
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)),
uniqueId: () => {
const pad = require('./pad').pad; // Sidestep circular dependency
@ -296,6 +315,7 @@ const padutils = {
let globalExceptionHandler = null;
padutils.setupGlobalExceptionHandler = () => {
if (globalExceptionHandler == null) {
require('./vendors/gritter');
globalExceptionHandler = (e) => {
let type;
let err;
@ -382,7 +402,7 @@ const inThirdPartyIframe = () => {
// This file is included from Node so that it can reuse randomString, but Node doesn't have a global
// window object.
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=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
@ -391,8 +411,9 @@ if (typeof window !== 'undefined') {
//
// `Strict` is not used because it has few security benefits but significant usability drawbacks
// vs. `Lax`. See https://stackoverflow.com/q/41841880 for discussion.
exports.Cookies.defaults.sameSite = inThirdPartyIframe() ? 'None' : 'Lax';
exports.Cookies.defaults.secure = window.location.protocol === 'https:';
sameSite: inThirdPartyIframe() ? 'None' : 'Lax',
secure: window.location.protocol === 'https:',
});
}
exports.randomString = randomString;
exports.padutils = padutils;

View file

@ -72,19 +72,6 @@ exports.formatHooks = (hookSetName, html) => {
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) => {
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'.
@ -111,7 +98,7 @@ exports.update = async () => {
defs.parts = sortParts(parts);
defs.hooks = pluginUtils.extractHooks(defs.parts, 'hooks', exports.pathNormalization);
defs.loaded = true;
await callInit();
await Promise.all(Object.keys(defs.plugins).map((p) => hooks.aCallAll(`init_${p}`, {})));
};
exports.getPackages = async () => {

View file

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

View file

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

View file

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

View file

@ -5,11 +5,10 @@
;
%>
<!doctype html>
<% e.begin_block("htmlHead"); %>
<html class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
<% e.end_block(); %>
<head>
<% e.begin_block("htmlHead"); %>
<% e.end_block(); %>
<title><%=settings.title%></title>
<script>
/*
@ -34,6 +33,7 @@
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 name="robots" content="noindex, nofollow">
@ -54,9 +54,8 @@
<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/l10n.js?v=<%=settings.randomVersionString%>"></script>
<!-- head and body had been removed intentionally -->
</head>
<body>
<% e.begin_block("body"); %>
<!----------------------------->
@ -388,7 +387,7 @@
</div>
<div id="chatinputbox">
<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>
</div>
</div>
@ -443,27 +442,6 @@
<% e.begin_block("scripts"); %>
<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>
<!-- Include base packages manually (this help with debugging) -->
@ -500,6 +478,10 @@
plugins.baseURL = baseURL;
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
$(function() {
hooks.aCallAll('documentReady');
@ -522,4 +504,5 @@
</script>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
<% e.end_block(); %>
</body>
</html>

View file

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

View file

@ -1,9 +1,11 @@
'use strict';
const apiHandler = require('../../node/handler/APIHandler');
const io = require('socket.io-client');
const log4js = require('log4js');
const process = require('process');
const server = require('../../node/server');
const setCookieParser = require('set-cookie-parser');
const settings = require('../../node/utils/Settings');
const supertest = require('supertest');
const webaccess = require('../../node/hooks/express/webaccess');
@ -17,7 +19,8 @@ exports.baseUrl = null;
exports.httpServer = null;
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.
// https://github.com/mochajs/mocha/issues/2640
@ -34,10 +37,10 @@ exports.init = async function () {
agentPromise = new Promise((resolve) => { agentResolve = resolve; });
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.');
log4js.setGlobalLogLevel(log4js.levels.OFF);
exports.logger.setLevel(logLevel);
logger.setLevel(logLevel);
}
// Note: This is only a shallow backup.
@ -49,7 +52,7 @@ exports.init = async function () {
settings.commitRateLimiting = {duration: 0.001, points: 1e6};
exports.httpServer = await server.start();
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.
exports.agent = supertest(exports.baseUrl);
// Speed up authn tests.
@ -67,3 +70,117 @@ exports.init = async function () {
agentResolve(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.
*/
const assert = require('assert').strict;
const common = require('../../common');
const fs = require('fs');
const fsp = fs.promises;
let agent;
const apiKey = common.apiKey;
@ -19,80 +21,52 @@ const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?api
describe(__filename, function () {
before(async function () { agent = await common.init(); });
describe('Connectivity For Character Encoding', function () {
it('can connect', function (done) {
this.timeout(250);
agent.get('/api/')
.expect('Content-Type', /json/)
.expect(200, done);
});
describe('Sanity checks', function () {
it('can connect', async function () {
await agent.get('/api/')
.expect(200)
.expect('Content-Type', /json/);
});
describe('API Versioning', function () {
this.timeout(150);
it('finds the version tag', function (done) {
agent.get('/api/')
.expect((res) => {
it('finds the version tag', async function () {
const res = await agent.get('/api/')
.expect(200);
apiVersion = res.body.currentVersion;
if (!res.body.currentVersion) throw new Error('No version set in API');
return;
})
.expect(200, done);
});
assert(apiVersion);
});
describe('Permission', function () {
it('errors with invalid APIKey', function (done) {
this.timeout(150);
it('errors with invalid APIKey', async function () {
// 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
const permErrorURL = `/api/${apiVersion}/createPad?apikey=password&padID=test`;
agent.get(permErrorURL)
.expect(401, done);
await agent.get(`/api/${apiVersion}/createPad?apikey=password&padID=test`)
.expect(401);
});
});
describe('createPad', function () {
it('creates a new Pad', function (done) {
this.timeout(150);
agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to create new Pad');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
describe('Tests', function () {
it('creates a new Pad', async function () {
const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
describe('setHTML', function () {
it('Sets the HTML of a Pad attempting to weird utf8 encoded content', function (done) {
this.timeout(1000);
fs.readFile('tests/backend/specs/api/emojis.html', 'utf8', (err, html) => {
agent.post(endPoint('setHTML'))
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,
html: await fsp.readFile('tests/backend/specs/api/emojis.html', 'utf8'),
})
.expect((res) => {
if (res.body.code !== 0) throw new Error("Can't set HTML properly");
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
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);
it('get the HTML of Pad with emojis', async function () {
const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.match(res.body.data.html, /&#127484/);
});
});
});

View file

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

View file

@ -31,7 +31,6 @@ describe(__filename, function () {
describe('Connectivity', function () {
it('can connect', async function () {
this.timeout(250);
await agent.get('/api/')
.expect(200)
.expect('Content-Type', /json/);
@ -40,7 +39,6 @@ describe(__filename, function () {
describe('API Versioning', function () {
it('finds the version tag', async function () {
this.timeout(250);
await agent.get('/api/')
.expect(200)
.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 () {
this.timeout(500);
await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/)
@ -109,28 +106,80 @@ describe(__filename, function () {
.expect((res) => assert.equal(res.body.data.text, padText.toString()));
});
describe('export from read-only pad ID', function () {
let readOnlyId;
// This ought to be before(), but it must run after the top-level beforeEach() above.
beforeEach(async function () {
if (readOnlyId != null) return;
await agent.post(`/p/${testPadId}/import`)
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(200);
const res = await agent.get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => assert.equal(res.body.code, 0));
readOnlyId = res.body.data.readOnlyID;
});
for (const authn of [false, true]) {
it(`can export from read-only pad ID, authn ${authn}`, async function () {
this.timeout(250);
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;
const get = (ep) => {
let req = agent.get(ep);
});
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');
return req.expect(200);
};
const ro = await get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`)
.expect((res) => assert.ok(JSON.parse(res.text).data.readOnlyID));
const readOnlyId = JSON.parse(ro.text).data.readOnlyID;
await get(`/p/${readOnlyId}/export/html`)
.expect((res) => assert(res.text.indexOf('This is the') !== -1));
await get(`/p/${readOnlyId}/export/txt`)
.expect((res) => assert(res.text.indexOf('This is the') !== -1));
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 () {
this.timeout(10000);
before(async function () {
if ((!settings.abiword || settings.abiword.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 () {
it('can connect', function (done) {
this.timeout(150);
agent.get('/api/')
.expect('Content-Type', /json/)
.expect(200, done);
@ -27,7 +26,6 @@ describe(__filename, function () {
describe('getStats', function () {
it('Gets the stats of a running instance', function (done) {
this.timeout(100);
agent.get(endPoint('getStats'))
.expect((res) => {
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 () {
it('errors if can not connect', async function () {
this.timeout(200);
await agent.get('/api/')
.expect(200)
.expect((res) => {
@ -60,7 +59,6 @@ describe(__filename, function () {
describe('API: Group creation and deletion', function () {
it('createGroup', async function () {
this.timeout(100);
await agent.get(endPoint('createGroup'))
.expect(200)
.expect('Content-Type', /json/)
@ -72,7 +70,6 @@ describe(__filename, function () {
});
it('listSessionsOfGroup for empty group', async function () {
this.timeout(100);
await agent.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -83,7 +80,6 @@ describe(__filename, function () {
});
it('deleteGroup', async function () {
this.timeout(100);
await agent.get(`${endPoint('deleteGroup')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -93,7 +89,6 @@ describe(__filename, function () {
});
it('createGroupIfNotExistsFor', async function () {
this.timeout(100);
await agent.get(`${endPoint('createGroupIfNotExistsFor')}&groupMapper=management`)
.expect(200)
.expect('Content-Type', /json/)
@ -106,7 +101,6 @@ describe(__filename, function () {
// Test coverage for https://github.com/ether/etherpad-lite/issues/4227
// Creates a group, creates 2 sessions, 2 pads and then deletes the group.
it('createGroup', async function () {
this.timeout(100);
await agent.get(endPoint('createGroup'))
.expect(200)
.expect('Content-Type', /json/)
@ -118,7 +112,6 @@ describe(__filename, function () {
});
it('createAuthor', async function () {
this.timeout(100);
await agent.get(endPoint('createAuthor'))
.expect(200)
.expect('Content-Type', /json/)
@ -130,7 +123,6 @@ describe(__filename, function () {
});
it('createSession', async function () {
this.timeout(100);
await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` +
'&validUntil=999999999999')
.expect(200)
@ -143,7 +135,6 @@ describe(__filename, function () {
});
it('createSession', async function () {
this.timeout(100);
await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` +
'&validUntil=999999999999')
.expect(200)
@ -156,7 +147,6 @@ describe(__filename, function () {
});
it('createGroupPad', async function () {
this.timeout(100);
await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x1234567`)
.expect(200)
.expect('Content-Type', /json/)
@ -166,7 +156,6 @@ describe(__filename, function () {
});
it('createGroupPad', async function () {
this.timeout(100);
await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x12345678`)
.expect(200)
.expect('Content-Type', /json/)
@ -176,7 +165,6 @@ describe(__filename, function () {
});
it('deleteGroup', async function () {
this.timeout(100);
await agent.get(`${endPoint('deleteGroup')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -189,7 +177,6 @@ describe(__filename, function () {
describe('API: Author creation', function () {
it('createGroup', async function () {
this.timeout(100);
await agent.get(endPoint('createGroup'))
.expect(200)
.expect('Content-Type', /json/)
@ -201,7 +188,6 @@ describe(__filename, function () {
});
it('createAuthor', async function () {
this.timeout(100);
await agent.get(endPoint('createAuthor'))
.expect(200)
.expect('Content-Type', /json/)
@ -212,7 +198,6 @@ describe(__filename, function () {
});
it('createAuthor with name', async function () {
this.timeout(100);
await agent.get(`${endPoint('createAuthor')}&name=john`)
.expect(200)
.expect('Content-Type', /json/)
@ -224,7 +209,6 @@ describe(__filename, function () {
});
it('createAuthorIfNotExistsFor', async function () {
this.timeout(100);
await agent.get(`${endPoint('createAuthorIfNotExistsFor')}&authorMapper=chris`)
.expect(200)
.expect('Content-Type', /json/)
@ -235,7 +219,6 @@ describe(__filename, function () {
});
it('getAuthorName', async function () {
this.timeout(100);
await agent.get(`${endPoint('getAuthorName')}&authorID=${authorID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -248,7 +231,6 @@ describe(__filename, function () {
describe('API: Sessions', function () {
it('createSession', async function () {
this.timeout(100);
await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` +
'&validUntil=999999999999')
.expect(200)
@ -261,7 +243,6 @@ describe(__filename, function () {
});
it('getSessionInfo', async function () {
this.timeout(100);
await agent.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -274,7 +255,6 @@ describe(__filename, function () {
});
it('listSessionsOfGroup', async function () {
this.timeout(100);
await agent.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -285,7 +265,6 @@ describe(__filename, function () {
});
it('deleteSession', async function () {
this.timeout(100);
await agent.get(`${endPoint('deleteSession')}&sessionID=${sessionID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -295,7 +274,6 @@ describe(__filename, function () {
});
it('getSessionInfo of deleted session', async function () {
this.timeout(100);
await agent.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -307,7 +285,6 @@ describe(__filename, function () {
describe('API: Group pad management', function () {
it('listPads', async function () {
this.timeout(100);
await agent.get(`${endPoint('listPads')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -318,7 +295,6 @@ describe(__filename, function () {
});
it('createGroupPad', async function () {
this.timeout(100);
await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=${padID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -329,7 +305,6 @@ describe(__filename, function () {
});
it('listPads after creating a group pad', async function () {
this.timeout(100);
await agent.get(`${endPoint('listPads')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -342,7 +317,6 @@ describe(__filename, function () {
describe('API: Pad security', function () {
it('getPublicStatus', async function () {
this.timeout(100);
await agent.get(`${endPoint('getPublicStatus')}&padID=${padID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -353,7 +327,6 @@ describe(__filename, function () {
});
it('setPublicStatus', async function () {
this.timeout(100);
await agent.get(`${endPoint('setPublicStatus')}&padID=${padID}&publicStatus=true`)
.expect(200)
.expect('Content-Type', /json/)
@ -363,7 +336,6 @@ describe(__filename, function () {
});
it('getPublicStatus after changing public status', async function () {
this.timeout(100);
await agent.get(`${endPoint('getPublicStatus')}&padID=${padID}`)
.expect(200)
.expect('Content-Type', /json/)
@ -380,7 +352,6 @@ describe(__filename, function () {
describe('API: Misc', function () {
it('listPadsOfAuthor', async function () {
this.timeout(100);
await agent.get(`${endPoint('listPadsOfAuthor')}&authorID=${authorID}`)
.expect(200)
.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 assert = require('assert').strict;
const cheerio = require('cheerio');
const contentcollector = require('../../../static/js/contentcollector');
const jsdom = require('jsdom');
const tests = {
nestedLi: {
@ -285,15 +285,13 @@ describe(__filename, function () {
}
it(testObj.description, async function () {
this.timeout(250);
const $ = cheerio.load(testObj.html); // Load HTML into Cheerio
const doc = $('body')[0]; // Creates a dom-like representation of HTML
const {window: {document}} = new jsdom.JSDOM(testObj.html);
// Create an empty attribute pool
const apool = new AttributePool();
// Convert a dom tree into a list of lines and attribute liens
// using the content collector object
const cc = contentcollector.makeContentCollector(true, null, apool);
cc.collectContent(doc);
cc.collectContent(document.body);
const result = cc.finish();
const gotAttributes = result.lineAttribs;
const wantAttributes = testObj.wantLineAttribs;

View file

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

View file

@ -2,96 +2,11 @@
const assert = require('assert').strict;
const common = require('../common');
const io = require('socket.io-client');
const padManager = require('../../../node/db/PadManager');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const readOnlyManager = require('../../../node/db/ReadOnlyManager');
const setCookieParser = require('set-cookie-parser');
const settings = require('../../../node/utils/Settings');
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;
};
const socketIoRouter = require('../../../node/handler/SocketIORouter');
describe(__filename, function () {
this.timeout(30000);
@ -142,38 +57,33 @@ describe(__filename, function () {
describe('Normal accesses', function () {
it('!authn anonymous cookie /p/pad -> 200, ok', async function () {
this.timeout(600);
const res = await agent.get('/p/pad').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('!authn !cookie -> ok', async function () {
this.timeout(400);
socket = await connect(null);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(null);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
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);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('authn user /p/pad -> 200, ok', async function () {
this.timeout(400);
settings.requireAuthentication = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
for (const authn of [false, true]) {
const desc = authn ? 'authn user' : '!authn anonymous';
it(`${desc} read-only /p/pad -> 200, ok`, async function () {
this.timeout(400);
const get = (ep) => {
let res = agent.get(ep);
if (authn) res = res.auth('user', 'user-password');
@ -181,32 +91,30 @@ describe(__filename, function () {
};
settings.requireAuthentication = authn;
let res = await get('/p/pad');
socket = await connect(res);
let clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
let clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
const readOnlyId = clientVars.data.readOnlyId;
assert(readOnlyManager.isReadOnlyId(readOnlyId));
socket.close();
res = await get(`/p/${readOnlyId}`);
socket = await connect(res);
clientVars = await handshake(socket, readOnlyId);
socket = await common.connect(res);
clientVars = await common.handshake(socket, readOnlyId);
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true);
});
}
it('authz user /p/pad -> 200, ok', async function () {
this.timeout(400);
settings.requireAuthentication = true;
settings.requireAuthorization = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('supports pad names with characters that must be percent-encoded', async function () {
this.timeout(400);
settings.requireAuthentication = true;
// 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
@ -215,58 +123,54 @@ describe(__filename, function () {
settings.requireAuthorization = true;
const encodedPadId = encodeURIComponent('päd');
const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'päd');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'päd');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
});
describe('Abnormal access attempts', function () {
it('authn anonymous /p/pad -> 401, error', async function () {
this.timeout(400);
settings.requireAuthentication = true;
const res = await agent.get('/p/pad').expect(401);
// Despite the 401, try to create the pad via a socket.io connection anyway.
socket = await connect(res);
const message = await handshake(socket, 'pad');
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('authn anonymous read-only /p/pad -> 401, error', async function () {
this.timeout(400);
settings.requireAuthentication = true;
let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
const readOnlyId = clientVars.data.readOnlyId;
assert(readOnlyManager.isReadOnlyId(readOnlyId));
socket.close();
res = await agent.get(`/p/${readOnlyId}`).expect(401);
// Despite the 401, try to read the pad via a socket.io connection anyway.
socket = await connect(res);
const message = await handshake(socket, readOnlyId);
socket = await common.connect(res);
const message = await common.handshake(socket, readOnlyId);
assert.equal(message.accessStatus, 'deny');
});
it('authn !cookie -> error', async function () {
this.timeout(400);
settings.requireAuthentication = true;
socket = await connect(null);
const message = await handshake(socket, 'pad');
socket = await common.connect(null);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('authorization bypass attempt -> error', async function () {
this.timeout(400);
// Only allowed to access /p/pad.
authorize = (req) => req.path === '/p/pad';
settings.requireAuthentication = true;
settings.requireAuthorization = true;
// First authenticate and establish a session.
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.
const message = await handshake(socket, 'other-pad');
const message = await common.handshake(socket, 'other-pad');
assert.equal(message.accessStatus, 'deny');
});
});
@ -278,66 +182,59 @@ describe(__filename, function () {
});
it("level='create' -> can create", async function () {
this.timeout(400);
authorize = () => 'create';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('level=true -> can create', async function () {
this.timeout(400);
authorize = () => true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it("level='modify' -> can modify", async function () {
this.timeout(400);
await padManager.getPad('pad'); // Create the pad.
authorize = () => 'modify';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it("level='create' settings.editOnly=true -> unable to create", async function () {
this.timeout(400);
authorize = () => 'create';
settings.editOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='modify' settings.editOnly=false -> unable to create", async function () {
this.timeout(400);
authorize = () => 'modify';
settings.editOnly = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='readOnly' -> unable to create", async function () {
this.timeout(400);
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='readOnly' -> unable to modify", async function () {
this.timeout(400);
await padManager.getPad('pad'); // Create the pad.
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true);
});
@ -349,56 +246,50 @@ describe(__filename, function () {
});
it('user.canCreate = true -> can create and modify', async function () {
this.timeout(400);
settings.users.user.canCreate = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('user.canCreate = false -> unable to create', async function () {
this.timeout(400);
settings.users.user.canCreate = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user.readOnly = true -> unable to create', async function () {
this.timeout(400);
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user.readOnly = true -> unable to modify', async function () {
this.timeout(400);
await padManager.getPad('pad'); // Create the pad.
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true);
});
it('user.readOnly = false -> can create and modify', async function () {
this.timeout(400);
settings.users.user.readOnly = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
socket = await common.connect(res);
const clientVars = await common.handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('user.readOnly = true, user.canCreate = true -> unable to create', async function () {
this.timeout(400);
settings.users.user.canCreate = true;
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
});
@ -410,23 +301,126 @@ describe(__filename, function () {
});
it('authorize hook does not elevate level from user settings', async function () {
this.timeout(400);
settings.users.user.readOnly = true;
authorize = () => 'create';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user settings does not elevate level from authorize hook', async function () {
this.timeout(400);
settings.users.user.readOnly = false;
settings.users.user.canCreate = true;
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
socket = await common.connect(res);
const message = await common.handshake(socket, 'pad');
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 settings = require('../../../node/utils/Settings');
@ -20,7 +22,6 @@ describe(__filename, function () {
describe('/javascript', function () {
it('/javascript -> 200', async function () {
this.timeout(200);
await agent.get('/javascript').expect(200);
});
});

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