自架 Overleaf (2)
[TOC]
前言
這篇是接續著前一篇「自架 Overleaf (1)」,要帶大家來幫自架的 Overleaf 加一些好料的。
(這個部落格現在真的是很醜,反正未來的某個時間一定會改善的,請眾讀者原諒。)
正文
先幫大家複習一下 Overleaf community edition 有哪些不方便的地方:
- 註冊新使用者很不方便
- admin panel 資訊和功能很少
- project 新增協作者時不會顯示「接受邀請的連結」
接下來將以這些不便之處為出發點,帶大家來 patch 這些功能。啊因為寫的當下有些東西我也還沒想過要怎麼改,所以沒有保證每個提出來的問題都會解決(不負責任的人)
本篇我所修改過的 code 都可以在 我的 repo 找到。
「首次登入即自動註冊」的功能
既然要加新功能,那我們就得要直接挖進 Overleaf server 的 source code 了。把 Overleaf 的 repo clone 下來,發揮一點逆向工程的精神(其實就只是讀別人 source code 而已講那麼高級),可以發現幾個我們有興趣的檔案:
services/web/app/src/Features/Authentication/AuthenticationManager.js
authenticate()
函數負責處理使用者的登入請求,會呼叫_checkUserPassword()
來檢查帳密_checkUserPassword()
試圖從 mongodb 裡找到該使用者的登入資訊並比對
services/web/app/src/Features/User/UserRegistrationHandler.js
- 這個檔案提供了註冊新使用者的
registerNewUser()
函數
- 這個檔案提供了註冊新使用者的
所以看起來,我們只要在 AuthenticationManager._checkUserPassword()
裡面加上「找不到使用者的話就呼叫 UserRegistrationHandler.registerNewUser()
幫他註冊」的功能就解決了。
仔細讀了一下 AuthenticationManager.js
,發現在這個檔案裡完全沒有 async/await 或 Promise 的存在,全部都是 callback 地獄。我們要對 AuthenticationManager.js
做的修改是「在一進 _checkUserPassword()
就檢查使用者存不存在,不存在的話幫他註冊」,也就是說我們只想加一個註冊的邏輯,不想改他的驗證邏輯。這時我們遇到了幾個語法上的選擇:
- 加入 callback 地獄,把 這 50 幾行 code 全部按一個 tab,讓他成為我們檢查使用者存不存在的 callback
- 大概像這樣:
_checkUserPassword(query, password, callback) { User.findOne(query, (error, user) => { // 我們檢查使用者存不存在 // 不存在的話就幫他註冊 /* 原本 _checkUserPassword() 的 code 原封不動放這下面 */ // Using Mongoose for legacy reasons here. The returned User instance // gets serialized into the session and there may be subtle differences // between the user returned by Mongoose vs mongodb (such as default values) User.findOne(query, (error, user) => { ... }) } }
- 我們真正新增的功能大概就 10 幾行 code,而已,這個縮排有 50 幾行,所以 commit 紀錄裡面會有超過 80% 的 code 都是不重要的更動,真棒真乾淨(才沒有
- 大概像這樣:
- 把
authenticate()
和_checkUserPassword()
都 refactor 成 async/await 的寫法,免除 commit 紀錄裡 50 幾行只有 tab 的更動- 大概像這樣:
async _checkUserPassword(query, password) { if (!await User.findOneAsync(query)) { // 註冊新使用者 } /* 原本 _checkUserPassword() 的 code 稍微改一下放這下面 */ // Using Mongoose for legacy reasons here. The returned User instance // gets serialized into the session and there may be subtle differences // between the user returned by Mongoose vs mongodb (such as default values) const user = await User.findOneAsync(query); ... }
- 好你這樣還要改其他會 call 到
_checkUserPassword()
的所有地方,而且要記得在對的地方加 try/catch block 來做到原本有的 error handling 的功能
- 大概像這樣:
- 把原本的
_checkUserPassword()
改名,然後自己再寫一個_checkUserPassword()
。有點類似 hook 掉原本函數的概念- 大概像這樣:
_checkUserPassword(query, password, callback) { User.findOne(query, (error, user) => { // 我們檢查使用者存不存在 // 不存在的話就幫他註冊 _checkUserPasswordOriginal(query, passwword, callback); } }, /* 原本 _checkUserPassword() 的函數 rename,原封不動放這下面 */ _checkUserPasswordOriginal(query, password, callback) { ... }
- 大概像這樣:
顯然最後一種最乾淨。
首先我們先把 Overleaf 裡的 AuthenticationManager.js
複製到在上次 toolkit 裡的 src/
資料夾中,再編輯 src/Dockerfile
在下面加上兩行:
FROM sharelatex/sharelatex:4.2.2
RUN tlmgr update --self --all
# install full latex packages, takes VERY VERY long time
RUN tlmgr install scheme-full
ARG SRC_PATH
COPY ${SRC_PATH}/AuthenticationManager.js /overleaf/services/web/app/src/Features/Authentication/AuthenticationManager.js
Note: 上次在
config/docker-compose.override.yml
裡有放的 build argumentSRC_PATH
就是要在這邊用的。
接著改 src/AuthenticationManager.js
裡的 _checkUserPassword()
函數:
const AuthenticationManager = {
_checkUserPassword(query, password, callback) {
// check if user exists, create a new user if not
User.findOne(query, (error, user) => {
if (error) {
return callback(error);
}
if (!user) {
// user not exists, create a new user
const UserRegistrationHandler = require('../User/UserRegistrationHandler');
const userDetail = {
email: query.email,
password: password,
};
UserRegistrationHandler.registerNewUser(userDetail, (err, user) => {
if (err) {
return callback(err);
}
AuthenticationManager._checkUserPasswordOriginal(query, password, callback);
});
} else {
AuthenticationManager._checkUserPasswordOriginal(query, password, callback);
}
})
},
_checkUserPasswordOriginal(query, password, callback) {
...
附一下現在的 directory structure:(其實就是多了一個 AuthenticationManager.js
而已)
overleaf-toolkit/
├── .github/
├── bin/
├── config/
│ ├── .gitkeep
│ ├── docker-compose.override.yml
│ └── ...
├── data/
├── doc/
├── lib/
├── src/
│ ├── Dockerfile
│ └── AuthenticationManager.js
├── .gitignore
└── ...
用 bin/docker-compose up --build -d
重新 build server,再到登入頁面隨便輸入一個沒註冊過的帳號密碼。
- email:
chiffon@example.com
- password:
12345678
Note: 如果 email 輸入
chiffon@localhost
的話,網站會跳出Something went wrong. Please try again.
的錯誤訊息。用bin/logs -n all web
看錯誤訊息,會發現錯誤發生在registerNewUser()
的 這行。繼續追下去的話,會發現chiffon@localhost
不是 合法的 email address,所以註冊的請求才會失敗。
用 bin/mongo
進到 mongo 的 shell,輸入 db.users.find({email: 'chiffon@example.com'}).pretty()
,可以看到確實成功註冊了新的使用者。web 界面登出之後,如果嘗試用錯的密碼登入,會顯示密碼錯誤的訊息,也表示我們成功註冊了使用者。
Note: 既然已經知道驗證使用者登入的邏輯放在哪個函數裡,我們就可以自己實做其他登入功能,例如 LDAP 和 OAuth 的登入方法。(其實 LDAP 的功能在付費版的 Overleaf Server Pro 有提供,但 GitHub 上搜尋 “overleaf ldap” 的話也找得到一些其他人 fork 的 project。本系列文章就是受到那些 project 的啟發而產生的)
讓使用者用 username 而不是 email 註冊
事實上,我們的 Overleaf server 沒有開啟可以寄 email 的功能,所以其實我們可以不用要求使用者一定要用 email 來註冊。又或是如果我們想要在前一個步驟把登入的驗證外包給其他服務(例如 LDAP 之類的),那也可能不一定有 email 的資訊可以用。最單純的改法可能在註冊的時候去跳過 email 的檢查,這個作法乍看之下最簡單,不用去改動到 database 的 schema 或是 Overleaf 的 model,但仔細爬了一下 source code,會發現除了在註冊的地方之外,還有很多其他地方都還是要檢查 email 的格式,例如邀請協作者、密碼重設、更新使用者資訊等地方,所以單純 patch 掉註冊處的 email 檢查會造成這些功能都無法正常運作。
我自己嘗試過的 patch 方法是我們將使用者輸入的 username 加上一串 email suffix 再拿去註冊,這樣就可以造成一個假象:對使用者來說他是用 username 來註冊,但對 Overleaf 來說他是用合法的 email 在處理這個使用者。可以加在前端(前端 <form>
送出 POST 的時候幫 username 加 email suffix),也可以在後端的 AuthenticationManager.authenticate()
去修改 query.email
的值(加一行 query.email = query.email + '@example.com'
之類的在函數一開頭)。雖然兩種 patch 的方法都可以做到讓使用者用 username 登入,但我們很在乎的一項功能——邀請協作者——就沒辦法用 username 來做到,除非我們也 patch 邀請協作者那部份的 code。
比較完整的 patch 方法可能是多出一個註冊的頁面,讓使用者在註冊時填寫 username, email, password,並將 username 也存到 database 中。這樣登入時可以選擇用 username 來搜尋 database,也不會與其他需要用到 email 的功能互相衝突。但是,這種作法要改動到的 code 比較多,會包含 user 的 model,建立一個註冊帳號專用的前後端,還有登入的邏輯。要改的東西太多了,那就改天吧(懶人用爛梗)
有興趣者歡迎與我討論細節。
停用 launchpad 的功能
在先前的章節,我們已經做到可以在首次登入時自動註冊使用者了。如此一來,在整個 server 第一次啟動以來,我們似乎沒有創建 admin 使用者的必要性。然而,爬一下 Overleaf 的 source code,會發現如果 database 當中沒有任何 admin 使用者的存在,launchpad 就一直都可以被存取。稍微做個小實驗,把 toolkit 資料夾下的 data/
清空(可能需要 root 權限,因為裡面都是透過 docker volume 存的資料),再重新啟動 Overleaf server,隨便創幾個使用者之後再前往 http://localhost:8080/launchpad
(請自行修改 URL 以符合你自己部署的環境),會發現這個 endpoint 仍然可以被存取。也就是說,任何一個可以連到你 server 的路人都可以把這個「創第一個 admin 使用者」的名額用掉。
要把 launchpad 的功能關掉其實超單純,直接上 code:
- 複製
services/web/modules/launchpad/app/src/LaunchpadRouter.js
到 toolkit 的src/
裡,直接把apply()
的函數 return 掉:module.exports = { apply(webRouter) { return; // add this new line to disable launchpad ... }, }
- 改動 Dockerfile,在最後面新增
LaunchpadRouter.js
... COPY ${SRC_PATH}/AuthenticationManager.js /overleaf/services/web/app/src/Features/Authentication/AuthenticationManager.js COPY ${SRC_PATH}/LaunchpadRouter.js /overleaf/services/web/modules/launchpad/app/src/LaunchpadRouter.js
好了之後就可以 bin/docker-compose up --build -d
來重 build 一次。開好之後可以前往 launchpad 測試看看,應該會發現 404 或 redirect 回首頁。
Note: 如果發現清空
data/*
之後bin/docker-compose logs sharelatex
出現了沒辦法連接上 mongo 的錯誤訊息,有可能需要參考 issue 解決。
總結 & 預告
在這篇文章中,我們延續了上篇的架構,開始深入地挖進 Overleaf 的 source code,找到目標功能的程式碼並嘗試 patch 成我們想要的功能。具體而言有「首次登入即註冊」和「停用 launchpad」兩個功能。這兩個功能都只有牽涉到網頁中的後端的程式碼。
在下一篇,我將帶大家一起嘗試修改前端的內容,也就是如何在邀請別人加入 project 協作者的時候,可以從前端複製接受該邀請的連結。
感謝閱讀。
後記
本來是打算在一天內把剩的想寫的東西全部放在這篇裡面趕快結束掉,但沒想到真的要寫文章的時候發現有好多細節本來沒考慮清楚,就花了比想像中多的時間泡在 source code 裡。更慘的是,我有嘗試要做顯示連結的功能,但前端的 code 似乎不像後端的 code 一樣,改了再 rebuild 就可以跑起來。我猜 React 那些前端一定在哪裡事先都編好了,我在 Dockerfile 裡 patch 東西根本沒反應。慟扣。
希望我真的會把第三篇寫出來。看前端好累。