(socket:any, message: {
data: ClientUserChangesMessage
})
| 822 | * @param message the message from the client |
| 823 | */ |
| 824 | const handleUserChanges = async (socket:any, message: { |
| 825 | data: ClientUserChangesMessage |
| 826 | }) => { |
| 827 | // This one's no longer pending, as we're gonna process it now |
| 828 | stats.counter('pendingEdits').dec(); |
| 829 | |
| 830 | // The client might disconnect between our callbacks. We should still |
| 831 | // finish processing the changeset, so keep a reference to the session. |
| 832 | const thisSession = sessioninfos[socket.id]; |
| 833 | |
| 834 | // TODO: this might happen with other messages too => find one place to copy the session |
| 835 | // and always use the copy. atm a message will be ignored if the session is gone even |
| 836 | // if the session was valid when the message arrived in the first place |
| 837 | if (!thisSession) throw new Error('client disconnected'); |
| 838 | |
| 839 | // Measure time to process edit. stats.timer('edits') spans the full handler |
| 840 | // (apply + fan-out) for backwards-compat; the new Prometheus histogram below |
| 841 | // wraps only the apply path so the scaling-dive harness can distinguish |
| 842 | // "apply is slow" from "fan-out is slow". Failed applies do not call the |
| 843 | // stopper — leaving the timer un-observed keeps the success-path |
| 844 | // distribution clean. |
| 845 | const stopWatch = stats.timer('edits').start(); |
| 846 | const stopApplyHistogram = recordChangesetApply(); |
| 847 | try { |
| 848 | const {data: {baseRev, apool, changeset}} = message; |
| 849 | if (baseRev == null) throw new Error('missing baseRev'); |
| 850 | if (apool == null) throw new Error('missing apool'); |
| 851 | if (changeset == null) throw new Error('missing changeset'); |
| 852 | const wireApool = (new AttributePool()).fromJsonable(apool); |
| 853 | const pad = await padManager.getPad(thisSession.padId, null, thisSession.author); |
| 854 | |
| 855 | // Verify that the changeset has valid syntax and is in canonical form |
| 856 | checkRep(changeset); |
| 857 | |
| 858 | // Validate all added 'author' attribs to be the same value as the current user. |
| 859 | // Exception: '=' ops (attribute changes on existing text) are allowed to restore other authors' |
| 860 | // IDs, but only if that author already exists in the pad's pool (i.e., they genuinely |
| 861 | // contributed to this pad). This is necessary for undoing "clear authorship colors", which |
| 862 | // re-applies the original author attributes for all authors. |
| 863 | // See https://github.com/ether/etherpad-lite/issues/2802 |
| 864 | for (const op of deserializeOps(unpack(changeset).ops)) { |
| 865 | // + can add text with attribs |
| 866 | // = can change or add attribs |
| 867 | // - can have attribs, but they are discarded and don't show up in the attribs - |
| 868 | // but do show up in the pool |
| 869 | |
| 870 | // Besides verifying the author attribute, this serves a second purpose: |
| 871 | // AttributeMap.fromString() ensures that all attribute numbers are valid (it will throw if |
| 872 | // an attribute number isn't in the pool). |
| 873 | const opAuthorId = AttributeMap.fromString(op.attribs, wireApool).get('author'); |
| 874 | if (opAuthorId && opAuthorId !== thisSession.author) { |
| 875 | if (op.opcode === '=') { |
| 876 | // Allow restoring author attributes on existing text (undo of clear authorship), |
| 877 | // but only if the author ID is already known to this pad. This prevents a user |
| 878 | // from attributing text to a fabricated author who never contributed to the pad. |
| 879 | const knownAuthor = pad.pool.putAttrib(['author', opAuthorId], true) !== -1; |
| 880 | if (!knownAuthor) { |
| 881 | throw new Error(`Author ${thisSession.author} tried to set unknown author ` + |
no test coverage detected