diff --git a/doc/api/hooks_server-side.md b/doc/api/hooks_server-side.md index dd0b8599e..370e782ed 100644 --- a/doc/api/hooks_server-side.md +++ b/doc/api/hooks_server-side.md @@ -370,7 +370,8 @@ A plugin's authFailure function is only called if all of the following are true: Calling the provided callback with `[true]` tells Etherpad that the failure was handled and no further error handling is required. Calling the callback with `[]` or `undefined` defers error handling to the next authFailure plugin (if -any, otherwise fall back to HTTP basic authentication). +any, otherwise fall back to HTTP basic authentication for an authentication +failure or a generic 403 page for an authorization failure). Example: diff --git a/src/locales/diq.json b/src/locales/diq.json index 8ba96f358..c21ffb62a 100644 --- a/src/locales/diq.json +++ b/src/locales/diq.json @@ -85,6 +85,8 @@ "pad.modals.deleted.explanation": "Ena ped wedariye", "pad.modals.rateLimited": "Nısbeto kemeyeyın", "pad.modals.rateLimited.explanation": "Na pad re ßıma vêşi mesac rışto, coki ra irtibat bıriyayo.", + "pad.modals.rejected.explanation": "Server, terefê browseri ra rışiyaye yew mesac red kerdo.", + "pad.modals.rejected.cause": "Şıma wexto ke ped weyniyayış de server belka biyo rocane ya ziEtherpad de yew xeta bena. Pela reyna bar kerê.", "pad.modals.disconnected": "İrtibata şıma reyê", "pad.modals.disconnected.explanation": "Rovıteri ya irtibata şıma reyyê", "pad.modals.disconnected.cause": "Qay rovıtero nêkarên o. Ena xerpey deqam kena se idarekaranê sistemiya irtibat kewê", diff --git a/src/locales/fr.json b/src/locales/fr.json index 6b567dcbb..e8c9bd910 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -104,6 +104,8 @@ "pad.modals.deleted.explanation": "Ce bloc-notes a été supprimé.", "pad.modals.rateLimited": "Taux limité.", "pad.modals.rateLimited.explanation": "Vous avez envoyé trop de messages à ce bloc, il vous a donc déconnecté.", + "pad.modals.rejected.explanation": "Le serveur a rejeté un message qui a été envoyé par votre navigateur.", + "pad.modals.rejected.cause": "Le serveur peut avoir été mis à jour pendant que vous regardiez le bloc, ou il y a peut-être un bogue dans Etherpad. Essayez de recharger la page.", "pad.modals.disconnected": "Vous avez été déconnecté.", "pad.modals.disconnected.explanation": "La connexion au serveur a échoué.", "pad.modals.disconnected.cause": "Il se peut que le serveur soit indisponible. Si le problème persiste, veuillez en informer l’administrateur du service.", diff --git a/src/locales/mk.json b/src/locales/mk.json index b5d79d1f8..3f492c7d0 100644 --- a/src/locales/mk.json +++ b/src/locales/mk.json @@ -81,6 +81,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": "Опслужувачот може да е недостапен. Известете го администраторот ако ова продолжи да ви се случува.", diff --git a/src/locales/pms.json b/src/locales/pms.json index 05677597a..8ca0b3ff7 100644 --- a/src/locales/pms.json +++ b/src/locales/pms.json @@ -78,6 +78,8 @@ "pad.modals.deleted.explanation": "Ës feuj a l'é stàit eliminà.", "pad.modals.rateLimited": "Tass limità.", "pad.modals.rateLimited.explanation": "A l'ha mandà tròpi mëssagi a 's blòch-sì, antlora a l'ha dëscolegalo.", + "pad.modals.rejected.explanation": "Ël servent a l'ha arpossà un mëssagi mandà da sò navigador.", + "pad.modals.rejected.cause": "Ël servent a podrìa esse stàit agiornà antramentre che chiel a beicava ël blòch, o peul desse ch'a-i é un givo an Etherpad. Ch'a preuva a carié torna la pàgina.", "pad.modals.disconnected": "A l'é stàit dëscolegà", "pad.modals.disconnected.explanation": "La conession al servent a l'é perdusse", "pad.modals.disconnected.cause": "Ël servent a podrìa esse indisponìbil. Për piasì, ch'a anforma l'aministrator dël servissi si ël problema a persist.", diff --git a/src/locales/pt-br.json b/src/locales/pt-br.json index ebcb3be44..a458de3d0 100644 --- a/src/locales/pt-br.json +++ b/src/locales/pt-br.json @@ -9,6 +9,7 @@ "Lpagliari", "Luckas", "Macofe", + "Nsharkey", "Prilopes", "Rafaelff", "Rodrigo codignoli", @@ -94,6 +95,7 @@ "pad.modals.deleted.explanation": "Esta nota foi removida.", "pad.modals.rateLimited": "Limitado.", "pad.modals.rateLimited.explanation": "Você enviou muitas mensagens para este pad por isso será desconectado.", + "pad.modals.rejected.explanation": "O servidor rejeitou uma mensagem que foi enviada pelo seu navegador.", "pad.modals.disconnected": "Você foi desconectado.", "pad.modals.disconnected.explanation": "A conexão com o servidor foi perdida", "pad.modals.disconnected.cause": "O servidor pode estar indisponível. Por favor, notifique o administrador caso isso continue.", diff --git a/src/locales/sv.json b/src/locales/sv.json index fcef0a10c..3065ac079 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -83,6 +83,8 @@ "pad.modals.deleted.explanation": "Detta block har tagits bort.", "pad.modals.rateLimited": "Begränsad frekvens.", "pad.modals.rateLimited.explanation": "Du skickade för många meddelanden till detta block så du blev frånkopplad.", + "pad.modals.rejected.explanation": "Servern avvisade ett meddelande som skickades av din webbläsare.", + "pad.modals.rejected.cause": "Servern kan ha uppdaterats medan du visade blocket, eller så finns det kanske en bugg i Etherpad. Försök att ladda om sidan.", "pad.modals.disconnected": "Du har blivit frånkopplad.", "pad.modals.disconnected.explanation": "Anslutningen till servern avbröts", "pad.modals.disconnected.cause": "Servern kanske är otillgänglig. Var god meddela tjänstadministratören om detta fortsätter att hända.", diff --git a/src/locales/tr.json b/src/locales/tr.json index 6031cfd2c..7b5dfac0a 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -9,6 +9,7 @@ "Joseph", "McAang", "Meelo", + "MuratTheTurkish", "Trockya", "Vito Genovese" ] @@ -88,6 +89,8 @@ "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.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.", diff --git a/src/locales/zh-hant.json b/src/locales/zh-hant.json index 79adb64f0..bd4eb6d8d 100644 --- a/src/locales/zh-hant.json +++ b/src/locales/zh-hant.json @@ -87,6 +87,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": "伺服器可能無法使用。若此情況持續發生,請通知伺服器管理員。", diff --git a/src/node/handler/PadMessageHandler.js b/src/node/handler/PadMessageHandler.js index e311e9dbf..a106bc252 100644 --- a/src/node/handler/PadMessageHandler.js +++ b/src/node/handler/PadMessageHandler.js @@ -53,7 +53,7 @@ const rateLimiter = new RateLimiterMemory({ * readonlyPadId = The readonly pad id of the pad * readonly = Wether the client has only read access (true) or read/write access (false) * rev = That last revision that was send to this client - * author = the author name of this session + * author = the author ID used for this session */ var sessioninfos = {}; exports.sessioninfos = sessioninfos; @@ -219,7 +219,7 @@ exports.handleMessage = async function(client, message) } const {session: {user} = {}} = client.client.request; - const {accessStatus} = + const {accessStatus, authorID} = await securityManager.checkAccess(padId, auth.sessionID, auth.token, auth.password, user); if (accessStatus !== "grant") { @@ -227,6 +227,19 @@ exports.handleMessage = async function(client, message) client.json.send({ accessStatus }); return; } + if (thisSession.author != null && thisSession.author !== authorID) { + messageLogger.warn( + 'Rejecting message from client because the author ID changed mid-session.' + + ' Bad or missing token or sessionID?' + + ` socket:${client.id}` + + ` IP:${settings.disableIPlogging ? ANONYMOUS : remoteAddress[client.id]}` + + ` originalAuthorID:${thisSession.author}` + + ` newAuthorID:${authorID}` + + ` message:${message}`); + client.json.send({disconnect: 'rejected'}); + return; + } + thisSession.author = authorID; // Allow plugins to bypass the readonly message blocker if ((await hooks.aCallAll('handleMessageSecurity', {client, message})).some((w) => w === true)) { @@ -246,9 +259,9 @@ exports.handleMessage = async function(client, message) // Check what type of message we get and delegate to the other methods if (message.type === "CLIENT_READY") { - handleClientReady(client, message); + await handleClientReady(client, message); } else if (message.type === "CHANGESET_REQ") { - handleChangesetRequest(client, message); + await handleChangesetRequest(client, message); } else if(message.type === "COLLABROOM") { if (thisSession.readonly) { messageLogger.warn("Dropped message, COLLABROOM for readonly pad"); @@ -256,13 +269,13 @@ exports.handleMessage = async function(client, message) stats.counter('pendingEdits').inc() padChannels.emit(message.padId, {client: client, message: message}); // add to pad queue } else if (message.data.type === "USERINFO_UPDATE") { - handleUserInfoUpdate(client, message); + await handleUserInfoUpdate(client, message); } else if (message.data.type === "CHAT_MESSAGE") { - handleChatMessage(client, message); + await handleChatMessage(client, message); } else if (message.data.type === "GET_CHAT_MESSAGES") { - handleGetChatMessages(client, message); + await handleGetChatMessages(client, message); } else if (message.data.type === "SAVE_REVISION") { - handleSaveRevisionMessage(client, message); + await handleSaveRevisionMessage(client, message); } else if (message.data.type === "CLIENT_MESSAGE" && message.data.payload != null && message.data.payload.type === "suggestUserName") { @@ -271,7 +284,7 @@ exports.handleMessage = async function(client, message) messageLogger.warn("Dropped message, unknown COLLABROOM Data Type " + message.data.type); } } else if(message.type === "SWITCH_TO_PAD") { - handleSwitchToPad(client, message); + await handleSwitchToPad(client, message); } else { messageLogger.warn("Dropped message, unknown Message Type " + message.type); } @@ -334,14 +347,14 @@ exports.handleCustomMessage = function(padID, msgString) { * @param client the client that send this message * @param message the message from the client */ -function handleChatMessage(client, message) +async function handleChatMessage(client, message) { var time = Date.now(); var userId = sessioninfos[client.id].author; var text = message.data.text; var padId = sessioninfos[client.id].padId; - exports.sendChatMessageToPadClients(time, userId, text, padId); + await exports.sendChatMessageToPadClients(time, userId, text, padId); } /** @@ -450,7 +463,7 @@ function handleSuggestUserName(client, message) * @param client the client that send this message * @param message the message from the client */ -function handleUserInfoUpdate(client, message) +async function handleUserInfoUpdate(client, message) { // check if all ok if (message.data.userInfo == null) { @@ -481,8 +494,10 @@ function handleUserInfoUpdate(client, message) } // Tell the authorManager about the new attributes - authorManager.setAuthorColorId(author, message.data.userInfo.colorId); - authorManager.setAuthorName(author, message.data.userInfo.name); + const p = Promise.all([ + authorManager.setAuthorColorId(author, message.data.userInfo.colorId), + authorManager.setAuthorName(author, message.data.userInfo.name), + ]); var padId = session.padId; @@ -504,6 +519,9 @@ function handleUserInfoUpdate(client, message) // Send the other clients on the pad the update message client.broadcast.to(padId).json.send(infoMsg); + + // Block until the authorManager has stored the new attributes. + await p; } /** @@ -800,7 +818,7 @@ function _correctMarkersInPad(atext, apool) { return builder.toString(); } -function handleSwitchToPad(client, message) +async function handleSwitchToPad(client, message) { // clear the session and leave the room const currentSessionInfo = sessioninfos[client.id]; @@ -817,7 +835,7 @@ function handleSwitchToPad(client, message) // start up the new pad const newSessionInfo = sessioninfos[client.id]; createSessionInfoAuth(newSessionInfo, message); - handleClientReady(client, message); + await handleClientReady(client, message); } // Creates/replaces the auth object in the given session info. @@ -1124,8 +1142,6 @@ async function handleClientReady(client, message) // Save the current revision in sessioninfos, should be the same as in clientVars sessionInfo.rev = pad.getHeadRevisionNumber(); - sessionInfo.author = authorID; - // prepare the notification for the other users on the pad, that this user joined let messageToTheOtherUsers = { "type": "COLLABROOM", diff --git a/src/node/handler/SocketIORouter.js b/src/node/handler/SocketIORouter.js index a5220d2f4..23f3e459c 100644 --- a/src/node/handler/SocketIORouter.js +++ b/src/node/handler/SocketIORouter.js @@ -87,7 +87,7 @@ exports.setSocketIO = function(_socket) { if (clientAuthorized) { // client is authorized, everything ok - handleMessage(client, message); + await handleMessage(client, message); } else { // try to authorize the client if (message.padId !== undefined && message.sessionID !== undefined && message.token !== undefined && message.password !== undefined) { @@ -104,7 +104,7 @@ exports.setSocketIO = function(_socket) { if (accessStatus === "grant") { // access was granted, mark the client as authorized and handle the message clientAuthorized = true; - handleMessage(client, message); + await handleMessage(client, message); } else { // no access, send the client a message that tells him why messageLogger.warn("Authentication try failed:" + stringifyWithoutPassword(message)); @@ -127,13 +127,13 @@ exports.setSocketIO = function(_socket) { } // try to handle the message of this client -function handleMessage(client, message) +async function handleMessage(client, message) { if (message.component && components[message.component]) { // check if component is registered in the components array if (components[message.component]) { messageLogger.debug("from " + client.id + ": " + stringifyWithoutPassword(message)); - components[message.component].handleMessage(client, message); + await components[message.component].handleMessage(client, message); } } else { messageLogger.error("Can't route the message:" + stringifyWithoutPassword(message)); diff --git a/src/node/hooks/express/socketio.js b/src/node/hooks/express/socketio.js index b1406afd2..ffc280b5c 100644 --- a/src/node/hooks/express/socketio.js +++ b/src/node/hooks/express/socketio.js @@ -8,6 +8,7 @@ var padMessageHandler = require("../../handler/PadMessageHandler"); var cookieParser = require('cookie-parser'); var sessionModule = require('express-session'); +const util = require('util'); exports.expressCreateServer = function (hook_name, args, cb) { //init socket.io and redirect all requests to the MessageHandler @@ -48,32 +49,34 @@ exports.expressCreateServer = function (hook_name, args, cb) { // check whether the user has authenticated, then any random person on the Internet can read, // modify, or create any pad (unless the pad is password protected or an HTTP API session is // required). - var cookieParserFn = cookieParser(webaccess.secret, {}); - io.use((socket, next) => { - var data = socket.request; - if (!data.headers.cookie) { + const cookieParserFn = util.promisify(cookieParser(webaccess.secret, {})); + const getSession = util.promisify(args.app.sessionStore.get).bind(args.app.sessionStore); + io.use(async (socket, next) => { + const req = socket.request; + if (!req.headers.cookie) { // socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the // token and express_sid cookies have to be passed via a query parameter for unit tests. - data.headers.cookie = socket.handshake.query.cookie; + req.headers.cookie = socket.handshake.query.cookie; } - if (!data.headers.cookie && settings.loadTest) { + if (!req.headers.cookie && settings.loadTest) { console.warn('bypassing socket.io authentication check due to settings.loadTest'); return next(null, true); } - const fail = (msg) => { return next(new Error(msg), false); }; - cookieParserFn(data, {}, function(err) { - if (err) return fail('access denied: unable to parse express_sid cookie'); - const expressSid = data.signedCookies.express_sid; - if (!expressSid) return fail ('access denied: signed express_sid cookie is required'); - args.app.sessionStore.get(expressSid, (err, session) => { - if (err || !session) return fail('access denied: bad session or session has expired'); - data.session = new sessionModule.Session(data, session); - if (settings.requireAuthentication && data.session.user == null) { - return fail('access denied: authentication required'); - } - next(null, true); - }); - }); + try { + await cookieParserFn(req, {}); + const expressSid = req.signedCookies.express_sid; + const needAuthn = settings.requireAuthentication; + if (needAuthn && !expressSid) throw new Error('signed express_sid cookie is required'); + if (expressSid) { + const session = await getSession(expressSid); + if (!session) throw new Error('bad session or session has expired'); + req.session = new sessionModule.Session(req, session); + if (needAuthn && req.session.user == null) throw new Error('authentication required'); + } + } catch (err) { + return next(new Error(`access denied: ${err}`), false); + } + return next(null, true); }); // var socketIOLogger = log4js.getLogger("socket.io"); diff --git a/src/node/hooks/express/webaccess.js b/src/node/hooks/express/webaccess.js index b83fbbd00..fd8c935e6 100644 --- a/src/node/hooks/express/webaccess.js +++ b/src/node/hooks/express/webaccess.js @@ -39,12 +39,13 @@ exports.checkAccess = (req, res, next) => { if (!level) return fail(); const user = req.session.user; if (user == null) return next(); // This will happen if authentication is not required. - const padID = (req.path.match(/^\/p\/(.*)$/) || [])[1]; - if (padID == null) return next(); + const encodedPadId = (req.path.match(/^\/p\/(.*)$/) || [])[1]; + if (encodedPadId == null) return next(); + const padId = decodeURIComponent(encodedPadId); // The user was granted access to a pad. Remember the authorization level in the user's // settings so that SecurityManager can approve or deny specific actions. if (user.padAuthorizations == null) user.padAuthorizations = {}; - user.padAuthorizations[padID] = level; + user.padAuthorizations[padId] = level; return next(); }; @@ -58,19 +59,6 @@ exports.checkAccess = (req, res, next) => { hooks.aCallFirst('authorize', {req, res, next, resource: req.path}, hookResultMangle(grant)); }; - /* Authentication OR authorization failed. */ - const failure = () => { - return hooks.aCallFirst('authFailure', {req, res, next}, hookResultMangle((ok) => { - if (ok) return; - // No plugin handled the authn/authz failure. Fall back to basic authentication. - res.header('WWW-Authenticate', 'Basic realm="Protected Area"'); - // Delay the error response for 1s to slow down brute force attacks. - setTimeout(() => { - res.status(401).send('Authentication Required'); - }, 1000); - })); - }; - // Access checking is done in three steps: // // 1) Try to just access the thing. If access fails (perhaps authentication has not yet completed, @@ -78,7 +66,7 @@ exports.checkAccess = (req, res, next) => { // 2) Try to authenticate. (Or, if already logged in, reauthenticate with different credentials if // supported by the authn scheme.) If authentication fails, give the user a 401 error to // request new credentials. Otherwise, go to the next step. - // 3) Try to access the thing again. If this fails, give the user a 401 error. + // 3) Try to access the thing again. If this fails, give the user a 403 error. // // Plugins can use the 'next' callback (from the hook's context) to break out at any point (e.g., // to process an OAuth callback). Plugins can use the authFailure hook to override the default @@ -103,6 +91,17 @@ exports.checkAccess = (req, res, next) => { } hooks.aCallFirst('authenticate', ctx, hookResultMangle((ok) => { if (!ok) { + const failure = () => { + return hooks.aCallFirst('authFailure', {req, res, next}, hookResultMangle((ok) => { + if (ok) return; + // No plugin handled the authentication failure. Fall back to basic authentication. + res.header('WWW-Authenticate', 'Basic realm="Protected Area"'); + // Delay the error response for 1s to slow down brute force attacks. + setTimeout(() => { + res.status(401).send('Authentication Required'); + }, 1000); + })); + }; // Fall back to HTTP basic auth. if (!httpBasicAuth) return failure(); if (!(ctx.username in settings.users)) { @@ -126,7 +125,13 @@ exports.checkAccess = (req, res, next) => { })); }; - step3Authorize = () => authorize(failure); + step3Authorize = () => authorize(() => { + return hooks.aCallFirst('authFailure', {req, res, next}, hookResultMangle((ok) => { + if (ok) return; + // No plugin handled the authorization failure. + res.status(403).send('Forbidden'); + })); + }); step1PreAuthenticate(); }; diff --git a/src/node/server.js b/src/node/server.js index c9ef33cc9..a8a567179 100755 --- a/src/node/server.js +++ b/src/node/server.js @@ -45,7 +45,7 @@ let started = false; let stopped = false; exports.start = async () => { - if (started) return; + if (started) return express.server; started = true; if (stopped) throw new Error('restart not supported'); diff --git a/src/package-lock.json b/src/package-lock.json index 6b9b9b0c8..7ff64c3b1 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -8235,6 +8235,77 @@ "requires": { "methods": "^1.1.2", "superagent": "^3.8.3" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "dev": true, + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + } + } } }, "supports-color": { diff --git a/src/package.json b/src/package.json index 9575ab117..ee45fa9a6 100644 --- a/src/package.json +++ b/src/package.json @@ -79,6 +79,7 @@ "mocha-froth": "^0.2.10", "nyc": "15.0.1", "set-cookie-parser": "^2.4.6", + "superagent": "^3.8.3", "supertest": "4.0.2", "wd": "1.12.1" }, diff --git a/tests/backend/specs/api/importexportGetPost.js b/tests/backend/specs/api/importexportGetPost.js index f678f7de7..aa72f7072 100644 --- a/tests/backend/specs/api/importexportGetPost.js +++ b/tests/backend/specs/api/importexportGetPost.js @@ -2,15 +2,14 @@ * Import and Export tests for the /p/whateverPadId/import and /p/whateverPadId/export endpoints. */ -const assert = require('assert'); +const assert = require('assert').strict; +const superagent = require(__dirname+'/../../../../src/node_modules/superagent'); const supertest = require(__dirname+'/../../../../src/node_modules/supertest'); const fs = require('fs'); const settings = require(__dirname+'/../../../../src/node/utils/Settings'); const host = 'http://127.0.0.1:'+settings.port; -const api = supertest('http://'+settings.ip+":"+settings.port); +const agent = supertest(`http://${settings.ip}:${settings.port}`); const path = require('path'); -const async = require(__dirname+'/../../../../src/node_modules/async'); -const request = require(__dirname+'/../../../../src/node_modules/request'); const padText = fs.readFileSync("../tests/backend/specs/api/test.txt"); const etherpadDoc = fs.readFileSync("../tests/backend/specs/api/test.etherpad"); const wordDoc = fs.readFileSync("../tests/backend/specs/api/test.doc"); @@ -23,26 +22,20 @@ var apiKey = fs.readFileSync(filePath, {encoding: 'utf-8'}); apiKey = apiKey.replace(/\n$/, ""); var apiVersion = 1; var testPadId = makeid(); -var lastEdited = ""; -var text = generateLongText(); describe('Connectivity', function(){ - it('can connect', function(done) { - api.get('/api/') - .expect('Content-Type', /json/) - .expect(200, done) + it('can connect', async function() { + await agent.get('/api/') + .expect(200) + .expect('Content-Type', /json/); }); }) describe('API Versioning', function(){ - it('finds the version tag', function(done) { - api.get('/api/') - .expect(function(res){ - apiVersion = res.body.currentVersion; - if (!res.body.currentVersion) throw new Error("No version set in API"); - return; - }) - .expect(200, done) + it('finds the version tag', async function() { + await agent.get('/api/') + .expect(200) + .expect((res) => assert(res.body.currentVersion)); }); }) @@ -73,289 +66,144 @@ Example Curl command for testing import URI: */ describe('Imports and Exports', function(){ - it('creates a new Pad, imports content to it, checks that content', function(done) { - if(!settings.allowAnyoneToImport){ - console.warn("not anyone can import so not testing -- to include this test set allowAnyoneToImport to true in settings.json"); - done(); - }else{ - api.get(endPoint('createPad')+"&padID="+testPadId) - .expect(function(res){ - if(res.body.code !== 0) throw new Error("Unable to create new Pad"); - - var req = request.post(host + '/p/'+testPadId+'/import', function (err, res, body) { - if (err) { - throw new Error("Failed to import", err); - } else { - api.get(endPoint('getText')+"&padID="+testPadId) - .expect(function(res){ - if(res.body.data.text !== padText.toString()){ - throw new Error("text is wrong on export"); - } - }) - } - }); - - let form = req.form(); - - form.append('file', padText, { - filename: '/test.txt', - contentType: 'text/plain' - }); - - }) - .expect('Content-Type', /json/) - .expect(200, done) + before(function() { + if (!settings.allowAnyoneToImport) { + console.warn('not anyone can import so not testing -- ' + + 'to include this test set allowAnyoneToImport to true in settings.json'); + this.skip(); } }); - // For some reason word import does not work in testing.. - // TODO: fix support for .doc files.. - it('Tries to import .doc that uses soffice or abiword', function(done) { - if(!settings.allowAnyoneToImport) return done(); - if((settings.abiword && settings.abiword.indexOf("/" === -1)) && (settings.office && settings.soffice.indexOf("/" === -1))) return done(); - - var req = request.post(host + '/p/'+testPadId+'/import', function (err, res, body) { - if (err) { - throw new Error("Failed to import", err); - } else { - if(res.body.indexOf("FrameCall('undefined', 'ok');") === -1){ - throw new Error("Failed DOC import", testPadId); - }else{ - done(); - } - } - }); - - let form = req.form(); - form.append('file', wordDoc, { - filename: '/test.doc', - contentType: 'application/msword' - }); + it('creates a new Pad, imports content to it, checks that content', async function() { + await agent.get(endPoint('createPad') + `&padID=${testPadId}`) + .expect(200) + .expect('Content-Type', /json/) + .expect((res) => assert.equal(res.body.code, 0)); + await agent.post(`/p/${testPadId}/import`) + .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) + .expect(200); + await agent.get(endPoint('getText') + `&padID=${testPadId}`) + .expect(200) + .expect((res) => assert.equal(res.body.data.text, padText.toString())); }); - it('exports DOC', function(done) { - if(!settings.allowAnyoneToImport) return done(); - if((settings.abiword && settings.abiword.indexOf("/" === -1)) && (settings.office && settings.soffice.indexOf("/" === -1))) return done(); - try{ - request(host + '/p/'+testPadId+'/export/doc', function (err, res, body) { - // TODO: At some point checking that the contents is correct would be suitable - if(body.length >= 9000){ - done(); - }else{ - throw new Error("Word Document export length is not right"); - } - }) - }catch(e){ - throw new Error(e); - } - }) - - it('Tries to import .docx that uses soffice or abiword', function(done) { - if(!settings.allowAnyoneToImport) return done(); - if((settings.abiword && settings.abiword.indexOf("/" === -1)) && (settings.office && settings.soffice.indexOf("/" === -1))) return done(); - - var req = request.post(host + '/p/'+testPadId+'/import', function (err, res, body) { - if (err) { - throw new Error("Failed to import", err); - } else { - if(res.body.indexOf("FrameCall('undefined', 'ok');") === -1){ - throw new Error("Failed DOCX import"); - }else{ - done(); - } + describe('Import/Export tests requiring AbiWord/LibreOffice', function() { + before(function() { + if ((!settings.abiword || settings.abiword.indexOf('/') === -1) && + (!settings.soffice || settings.soffice.indexOf('/') === -1)) { + this.skip(); } }); - let form = req.form(); - form.append('file', wordXDoc, { - filename: '/test.docx', - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - }); - }); - - it('exports DOC from imported DOCX', function(done) { - if(!settings.allowAnyoneToImport) return done(); - if((settings.abiword && settings.abiword.indexOf("/" === -1)) && (settings.office && settings.soffice.indexOf("/" === -1))) return done(); - request(host + '/p/'+testPadId+'/export/doc', function (err, res, body) { - // TODO: At some point checking that the contents is correct would be suitable - if(body.length >= 9100){ - done(); - }else{ - throw new Error("Word Document export length is not right"); - } - }) - }) - - it('Tries to import .pdf that uses soffice or abiword', function(done) { - if(!settings.allowAnyoneToImport) return done(); - if((settings.abiword && settings.abiword.indexOf("/" === -1)) && (settings.office && settings.soffice.indexOf("/" === -1))) return done(); - - var req = request.post(host + '/p/'+testPadId+'/import', function (err, res, body) { - if (err) { - throw new Error("Failed to import", err); - } else { - if(res.body.indexOf("FrameCall('undefined', 'ok');") === -1){ - throw new Error("Failed PDF import"); - }else{ - done(); - } - } + // For some reason word import does not work in testing.. + // TODO: fix support for .doc files.. + it('Tries to import .doc that uses soffice or abiword', async function() { + await agent.post(`/p/${testPadId}/import`) + .attach('file', wordDoc, {filename: '/test.doc', contentType: 'application/msword'}) + .expect(200) + .expect(/FrameCall\('undefined', 'ok'\);/); }); - let form = req.form(); - form.append('file', pdfDoc, { - filename: '/test.pdf', - contentType: 'application/pdf' - }); - }); - - it('exports PDF', function(done) { - if(!settings.allowAnyoneToImport) return done(); - if((settings.abiword && settings.abiword.indexOf("/" === -1)) && (settings.office && settings.soffice.indexOf("/" === -1))) return done(); - request(host + '/p/'+testPadId+'/export/pdf', function (err, res, body) { - // TODO: At some point checking that the contents is correct would be suitable - if(body.length >= 1000){ - done(); - }else{ - throw new Error("PDF Document export length is not right"); - } - }) - }) - - it('Tries to import .odt that uses soffice or abiword', function(done) { - if(!settings.allowAnyoneToImport) return done(); - if((settings.abiword && settings.abiword.indexOf("/" === -1)) && (settings.office && settings.soffice.indexOf("/" === -1))) return done(); - - var req = request.post(host + '/p/'+testPadId+'/import', function (err, res, body) { - if (err) { - throw new Error("Failed to import", err); - } else { - if(res.body.indexOf("FrameCall('undefined', 'ok');") === -1){ - throw new Error("Failed ODT import", testPadId); - }else{ - done(); - } - } + it('exports DOC', async function() { + await agent.get(`/p/${testPadId}/export/doc`) + .buffer(true).parse(superagent.parse['application/octet-stream']) + .expect(200) + .expect((res) => assert(res.body.length >= 9000)); }); - let form = req.form(); - form.append('file', odtDoc, { - filename: '/test.odt', - contentType: 'application/odt' - }); - }); - - it('exports ODT', function(done) { - if(!settings.allowAnyoneToImport) return done(); - if((settings.abiword && settings.abiword.indexOf("/" === -1)) && (settings.office && settings.soffice.indexOf("/" === -1))) return done(); - request(host + '/p/'+testPadId+'/export/odt', function (err, res, body) { - // TODO: At some point checking that the contents is correct would be suitable - if(body.length >= 7000){ - done(); - }else{ - throw new Error("ODT Document export length is not right"); - } - }) - }) - - it('Tries to import .etherpad', function(done) { - if(!settings.allowAnyoneToImport) return done(); - - var req = request.post(host + '/p/'+testPadId+'/import', function (err, res, body) { - if (err) { - throw new Error("Failed to import", err); - } else { - if(res.body.indexOf("FrameCall(\'true\', \'ok\');") === -1){ - throw new Error("Failed Etherpad import", err, testPadId); - }else{ - done(); - } - } - }); - - let form = req.form(); - form.append('file', etherpadDoc, { - filename: '/test.etherpad', - contentType: 'application/etherpad' - }); - }); - - it('exports Etherpad', function(done) { - request(host + '/p/'+testPadId+'/export/etherpad', function (err, res, body) { - // TODO: At some point checking that the contents is correct would be suitable - if(body.indexOf("hello") !== -1){ - done(); - }else{ - console.error("body"); - throw new Error("Etherpad Document does not include hello"); - } - }) - }) - - it('exports HTML for this Etherpad file', function(done) { - request(host + '/p/'+testPadId+'/export/html', function (err, res, body) { - - // broken pre fix export -- - var expectedHTML = ''; - // expect body to include - if(body.indexOf(expectedHTML) !== -1){ - done(); - }else{ - console.error(body); - throw new Error("Exported HTML nested list items is not right", body); - } - }) - }) - - it('tries to import Plain Text to a pad that does not exist', function(done) { - var req = request.post(host + '/p/'+testPadId+testPadId+testPadId+'/import', function (err, res, body) { - if (res.statusCode === 200) { - throw new Error("Was able to import to a pad that doesn't exist"); - }else{ - // Wasn't able to write to a pad that doesn't exist, this is expected behavior - api.get(endPoint('getText')+"&padID="+testPadId+testPadId+testPadId) - .expect(function(res){ - if(res.body.code !== 1) throw new Error("Pad Exists"); + it('Tries to import .docx that uses soffice or abiword', async function() { + await agent.post(`/p/${testPadId}/import`) + .attach('file', wordXDoc, { + filename: '/test.docx', + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }) - .expect(200, done) - } + .expect(200) + .expect(/FrameCall\('undefined', 'ok'\);/); + }); - let form = req.form(); + it('exports DOC from imported DOCX', async function() { + await agent.get(`/p/${testPadId}/export/doc`) + .buffer(true).parse(superagent.parse['application/octet-stream']) + .expect(200) + .expect((res) => assert(res.body.length >= 9100)); + }); - form.append('file', padText, { - filename: '/test.txt', - contentType: 'text/plain' - }); - }) + it('Tries to import .pdf that uses soffice or abiword', async function() { + await agent.post(`/p/${testPadId}/import`) + .attach('file', pdfDoc, {filename: '/test.pdf', contentType: 'application/pdf'}) + .expect(200) + .expect(/FrameCall\('undefined', 'ok'\);/); + }); + + it('exports PDF', async function() { + await agent.get(`/p/${testPadId}/export/pdf`) + .buffer(true).parse(superagent.parse['application/octet-stream']) + .expect(200) + .expect((res) => assert(res.body.length >= 1000)); + }); + + it('Tries to import .odt that uses soffice or abiword', async function() { + await agent.post(`/p/${testPadId}/import`) + .attach('file', odtDoc, {filename: '/test.odt', contentType: 'application/odt'}) + .expect(200) + .expect(/FrameCall\('undefined', 'ok'\);/); + }); + + it('exports ODT', async function() { + await agent.get(`/p/${testPadId}/export/odt`) + .buffer(true).parse(superagent.parse['application/octet-stream']) + .expect(200) + .expect((res) => assert(res.body.length >= 7000)); + }); + + }); // End of AbiWord/LibreOffice tests. + + it('Tries to import .etherpad', async function() { + await agent.post(`/p/${testPadId}/import`) + .attach('file', etherpadDoc, { + filename: '/test.etherpad', + contentType: 'application/etherpad', + }) + .expect(200) + .expect(/FrameCall\('true', 'ok'\);/); }); - it('Tries to import unsupported file type', function(done) { - if(settings.allowUnknownFileEnds === true){ - console.log("allowing unknown file ends so skipping this test"); - return done(); + it('exports Etherpad', async function() { + await agent.get(`/p/${testPadId}/export/etherpad`) + .buffer(true).parse(superagent.parse.text) + .expect(200) + .expect(/hello/); + }); + + it('exports HTML for this Etherpad file', async function() { + await agent.get(`/p/${testPadId}/export/html`) + .expect(200) + .expect('content-type', 'text/html; charset=utf-8') + .expect(/