了解如何為 Confluence 與 ONLYOFFICE 創(chuàng)建集成插件以在 Confluence 中添加 DOCX、XLSX 與 PPTX 編輯功能。

在線文檔編輯功能可作為數(shù)字化協(xié)同工作區(qū)的寶貴補(bǔ)充,為此您還可使用開(kāi)源工具。在本文中,我們將向您展示如何通過(guò)集成 ONLYOFFICE 文檔來(lái)在 Confluence(Atlassian 推出的網(wǎng)頁(yè)端企業(yè) Wiki)中引入文檔編輯功能。
我們將創(chuàng)建一個(gè)集成應(yīng)用程序(插件)來(lái)發(fā)揮溝通 Confluence 與 ONLYOFFICE 的橋梁作用。在其幫助下,用戶將可在 Confluence 中編輯 DOCX、XLSX、PPTX 和其他辦公文件。
安裝 ONLYOFFICE 文檔
在此過(guò)程中,我們將需要可正常工作的 ONLYOFFICE 文檔實(shí)例。其已被打包為了文檔服務(wù)器,所以我們將在本文中使用此說(shuō)法。
您可使用 Docker 來(lái)進(jìn)行安裝,或選擇其他安裝選項(xiàng):
docker run -i -t -d -p 8080:80 onlyoffice/documentserver
本行代碼將安裝免費(fèi)社區(qū)版。其中包含文檔、電子表格與演示文稿編輯器,此外還有集成示例(一個(gè)簡(jiǎn)單的文檔管理系統(tǒng)),其中演示了集成的各種功能,允許您在將編輯器集成至應(yīng)用之前對(duì)其進(jìn)行測(cè)試。
集成測(cè)試示例使用 Node.js 進(jìn)行構(gòu)建。您可查看其他語(yǔ)言的選項(xiàng),包括 Java 測(cè)試示例。
現(xiàn)在,我們還是回到創(chuàng)建將編輯器連接至 Confluence 的插件上來(lái)。
使用 Atlassian SDK
在進(jìn)行開(kāi)發(fā)工作之前,我們需要安裝 Atlassian SDK?;旧蟻?lái)說(shuō),這一 SDK 就是 Maven 的封裝器。您可借助它來(lái)創(chuàng)建插件主體,然后在運(yùn)行中的 Atlassian 應(yīng)用程序中對(duì)其進(jìn)行測(cè)試。
如需進(jìn)行安裝,可參考 Windows、Linux 或 Mac 的官方安裝指南。
之后我們將為 Confluence 創(chuàng)建一個(gè)插件主體。打開(kāi)命令行并運(yùn)行 atlas-create-confluence-plugin。在創(chuàng)建簡(jiǎn)單插件前,這一工具將要求您提供一些細(xì)節(jié)。
您也可以在 IDE 中打開(kāi)插件。部分 IDE 可能需要進(jìn)行額外配置。
雖然在對(duì) VSCode 進(jìn)行配置方面沒(méi)有太多說(shuō)明,但相關(guān)工作非常簡(jiǎn)單:您只需將其指向 SDK 自帶的 Maven 即可。在 .vscode 文件夾中創(chuàng)建 settings.json 并填入以下內(nèi)容:
{
"maven.executable.path": "C:\\Applications\\Atlassian\\atlassian-plugin-sdk-8.0.16\\apache-maven-3.5.4\\bin\\mvn\",
"java.configuration.maven.userSettings": "C:\\Applications\\Atlassian\\atlassian-plugin-sdk-8.0.16\\apache-maven-3.5.4\\conf\\settings.xml"
}
還要注意的是,具體路徑可能會(huì)因?yàn)?SDK 的安裝路徑不同而有所區(qū)別。
使用 UI
借助 Atlassian 應(yīng)用的 UI,您可實(shí)現(xiàn)很多功能,但是現(xiàn)在我們只需創(chuàng)建一個(gè)編輯按鈕和用于加載編輯器的頁(yè)面即可。
添加按鈕非常簡(jiǎn)單。實(shí)際上,您甚至都不需要編寫(xiě)代碼(除非按鈕背后還有額外的邏輯)?;旧隙?,Atlassian 應(yīng)用會(huì)使用一個(gè) XML 文件來(lái)聲明插件的內(nèi)容。文件位于:src\main\resources\atlassian-plugin.xml。此外還有助于讓我們了解應(yīng)用會(huì)對(duì)哪些 Java 類進(jìn)行實(shí)例化。
(小提醒:我們將在代碼中排除所有 import 行以節(jié)省空間)
如需添加按鈕,我們只需添加這些代碼即可:
<web-item key="onlyoffice-doceditor" name="Link for the attachment editing" section="system.attachment" weight="9">
condition class="onlyoffice.IsOfficeFileAttachment">
<param name="forEdit">;true</param>;
</condition>
<description>The link and the text for it to open the document which is available for editing.</description>
<label key="onlyoffice.connector.editlink"/>
<link><![CDATA[/plugins/servlet/onlyoffice/doceditor?attachmentId=$attachment.id]]></link>
<styleClass>onlyoffice-doceditor</styleClass>
</web-item>
下面就來(lái)看看上面幾行代碼中的參數(shù)。為此我們也準(zhǔn)備了一份詳盡的官方文檔,記得去看看。
大部分參數(shù)都無(wú)需過(guò)多解釋。section 屬性用于聲明我們希望建立鏈接的位置。<condition> 元素用于聲明對(duì)展示鏈接的附件進(jìn)行過(guò)濾的 Java 類。
基本上而言,這里的條件將用于檢查用戶的訪問(wèn)權(quán)限、最大文件大小以及文件擴(kuò)展名。如果您對(duì)于其工作原理比較感興趣,可在這里找到相關(guān)代碼。
當(dāng)然,我們需要添加的不僅僅是編輯按鈕。此外還有用于查看文檔和將其轉(zhuǎn)換為不同格式的按鈕,但主要的流程還是相同的。
接著我們來(lái)看看 <link> 元素。這里使用附件 id 參數(shù)將其導(dǎo)向 Servlet(鏈接如下:confluence/servlet/attachment_Id)。Servlet 將對(duì)用戶請(qǐng)求進(jìn)行處理并返回需要在編輯器中進(jìn)行加載的頁(yè)面。
Servlet 本質(zhì)上是繼承自 javax.servlet.http.HttpServlet的類,但需對(duì)其 doGet 方法進(jìn)行重寫(xiě)。這里我們還是保持簡(jiǎn)潔,暫時(shí)不去深究實(shí)現(xiàn)細(xì)節(jié)。
public class OnlyOfficeEditorServlet extends HttpServlet {
private static final Logger log = LogManager.getLogger("onlyoffice.OnlyOfficeEditorServlet");
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
return VelocityUtils.getRenderedTemplate("templates/editor.vm", defaults);
}
}
我們要做的唯一一件事是渲染一個(gè) Velocity 模板。所以:src\main\resources\templates\editor.vm。
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN” "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml\>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=ANSI" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width" />
<title>${docTitle} - ONLYOFFICE</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<style type="text/css">
html { height: 100%; width: 100%; }
body {
background: #fff; color: #333; font-family: Arial, Tahoma, sans-serif;
font-size: 12px; font-weight: normal; text-decoration: none;
height: 100%; margin: 0; overflow-y: hidden; padding: 0;
}
.form { height: 100%; }
div { margin: 0; padding: 0; }
</style>
</head>
<body>
<div class="form">
<div id="iframeEditor"></div>
</div>
</body>
</html>
在模板中將有一個(gè) JavaScript 腳本用于在編輯器中加載頁(yè)面。您可能會(huì)注意到這里有多個(gè) ${variables},我們會(huì)在稍后提供相關(guān)信息。為了讓 Servlet 能夠正常工作,我們需要讓 Atlassian 知曉其存在。所以,這里我們需要對(duì)于 atlassian-plugin.xml. 進(jìn)行一些修改。
<servlet key="OnlyOfficeDocEditor" class="onlyoffice.OnlyOfficeEditorServlet" name="Document Editor">
<description>The fully functional editor for most known formats of text documents, spreadsheets and presentations used to open these types of documents for editing or preview.</description>
<url-pattern>/onlyoffice/doceditor</url-pattern>
</servlet>
Servlet 和插件配置
此時(shí),我們馬上就能去打開(kāi)文檔進(jìn)行查看了?,F(xiàn)在我們還需要為頁(yè)面模板提供變量。其中一個(gè)將包含指向文檔服務(wù)器的 URL,這里最好不要直接寫(xiě)死在代碼中。幸運(yùn)的是,我們可以借助一個(gè)很棒的接口來(lái)對(duì)插件設(shè)置項(xiàng)與 UI 進(jìn)行操作
下面我們就來(lái)創(chuàng)建另一個(gè)配置 Servlet(別忘了將其添加至 atlassian-plugin.xml 中)。
public class OnlyOfficeConfServlet extends HttpServlet {
@ComponentImport
private final UserManager userManager;
@ComponentImport
private final PluginSettingsFactory pluginSettingsFactory;
@Inject
public OnlyOfficeConfServlet(UserManager userManager, PluginSettingsFactory pluginSettingsFactory) {
this.userManager = userManager;
this.pluginSettingsFactory = pluginSettingsFactory;
}
private static final Logger log = LogManager.getLogger("onlyoffice.OnlyOfficeConfServlet");
private static final long serialVersionUID = 1L;
private String AppendSlash(String str) {
if (str == null || str.isEmpty() || str.endsWith("/"))
return str;
return str + "/";
}
private String getBody(InputStream stream) {
Scanner scanner = null;
Scanner scannerUseDelimiter = null;
try {
scanner = new Scanner(stream);
scannerUseDelimiter = scanner.useDelimiter("\\A");
return scanner.hasNext() ? scanner.next() : "";
} finally {
scannerUseDelimiter.close();
scanner.close();
}
}
}
這里我們有兩種實(shí)用方法,代碼本身已足夠簡(jiǎn)單明了。我們還需要另外兩個(gè)方法。
第一個(gè)是 doGet。用于獲取當(dāng)前設(shè)置并將其提供給模板。
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String username = userManager.getRemoteUsername(request);
if (username == null || !userManager.isSystemAdmin(username)) {
SettingsManager settingsManager = (SettingsManager) ContainerManager.getComponent("settingsManager");
String baseUrl = settingsManager.getGlobalSettings().getBaseUrl();
response.sendRedirect(baseUrl);
return;
}
PluginSettings pluginSettings = pluginSettingsFactory.createGlobalSettings();
String apiUrl = (String) pluginSettings.get("onlyoffice.apiUrl");
response.setContentType("text/html;charset=UTF-8");
PrintWriter writer = response.getWriter();
Map<String, Object> contextMap = MacroUtils.defaultVelocityContext();
contextMap.put("docserviceApiUrl", apiUrl);
writer.write(getTemplate(contextMap));
}
private String getTemplate(Map<String, Object> map) throws UnsupportedEncodingException {
return VelocityUtils.getRenderedTemplate("templates/configure.vm", map);
}
第二個(gè)是 doPost。該方法將接收 JSON 對(duì)象,對(duì)其進(jìn)行解析并覆蓋當(dāng)前設(shè)置。
@Override
public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String username = userManager.getRemoteUsername(request);
if (username == null || !userManager.isSystemAdmin(username)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
String body = getBody(request.getInputStream());
if (body.isEmpty()) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
String apiUrl;
try {
JSONObject jsonObj = new JSONObject(body);
apiUrl = AppendSlash(jsonObj.getString("apiUrl"));
} catch (Exception ex) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
ex.printStackTrace(pw);
String error = ex.toString() + "\n" + sw.toString();
log.error(error);
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("{\"success\": false, \"message\": \"jsonparse\"}");
return;
}
PluginSettings pluginSettings = pluginSettingsFactory.createGlobalSettings();
pluginSettings.put("onlyoffice.apiUrl", apiUrl);
response.getWriter().write("{\"success\": true}");
}
在這兩種方法中,我們還會(huì)檢查用戶是否有權(quán)訪問(wèn)這些設(shè)置。實(shí)際上,我們還會(huì)檢查與文檔服務(wù)器之間的連接,以便對(duì)潛在問(wèn)題進(jìn)行識(shí)別。完整代碼可在此處查看。
我們還能在 Atlassian 插件管理頁(yè)面中添加一個(gè)配置按鈕。只需將此行代碼添加至 atlassian-plugin.xml 中的 <plugin-info> 元素內(nèi)即可。
<param name="configure.url">/plugins/servlet/onlyoffice/configure</param>
對(duì)了,別忘記 Velocity 模板。
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>ONLYOFFICE</title>
<meta name="decorator" content="atl.admin" />
</head>
<body>
<div id="onlyofficeMsg"></div>
<form id="onlyofficeConf" class="aui top-label">
<h3>$i18n.getText('onlyoffice.configuration.doc-section')</h3>
<div class="field-group">
<label for="apiUrlField">$i18n.getText('onlyoffice.configuration.doc-url')</label>
<input type="text" id="apiUrlField" value="${docserviceApiUrl}" name="apiUrlField" class="text onlyoffice-tooltip" title="$i18n.getText('onlyoffice.configuration.doc-url-tooltip')">
</div>
<div class="field-group">
<input id="onlyofficeSubmitBtn" type="submit" value="$i18n.getText('onlyoffice.configuration.save')" class="button">
</div>
</form>
</body>
</html>
下面讓我們返回編輯器 Servlet 并對(duì)其進(jìn)行修改。
public class OnlyOfficeEditorServlet extends HttpServlet {
@ComponentImport
private final LocaleManager localeManager;
private final UrlManager urlManager;
@Inject
public OnlyOfficeEditorServlet(LocaleManager localeManager, UrlManager urlManager) {
this.urlManager = urlManager;
this.localeManager = localeManager;
}
private static final Logger log = LogManager.getLogger("onlyoffice.OnlyOfficeEditorServlet");
private static final long serialVersionUID = 1L;
private Properties properties;
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
if (!AuthContext.checkUserAuthorisation(request, response)) {
return;
}
String apiUrl = urlManager.getPublicDocEditorUrl();
if (apiUrl == null || apiUrl.isEmpty()) {
apiUrl = "";
}
ConfigurationManager configurationManager = new ConfigurationManager();
properties = configurationManager.GetProperties();
String fileUrl = "";
String key = "";
String fileName = "";
String errorMessage = "";
ConfluenceUser user = null;
String attachmentIdString = request.getParameter("attachmentId");
Long attachmentId;
try {
attachmentId = Long.parseLong(attachmentIdString);
log.info("attachmentId " + attachmentId);
user = AuthenticatedUserThreadLocal.get();
log.info("user " + user);
if (AttachmentUtil.checkAccess(attachmentId, user, false)) {
key = DocumentManager.getKeyOfFile(attachmentId);
fileName = AttachmentUtil.getFileName(attachmentId);
fileUrl = urlManager.GetFileUri(attachmentId);
} else {
log.error("access deny");
errorMessage = "You don not have enough permission to view the file";
}
} catch (Exception ex) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
ex.printStackTrace(pw);
String error = ex.toString() + "\n" + sw.toString();
log.error(error);
errorMessage = ex.toString();
}
response.setContentType("text/html;charset=UTF-8");
PrintWriter writer = response.getWriter();
writer.write(getTemplate(apiUrl, fileUrl, key, fileName, user, errorMessage));
}
private String getTemplate(String apiUrl, String fileUrl, String key, String fileName,
ConfluenceUser user, String errorMessage) throws UnsupportedEncodingException {
Map<String, Object> defaults = MacroUtils.defaultVelocityContext();
Map<String, String> config = new HashMap<String, String>();
String docTitle = fileName.trim();
String docExt = docTitle.substring(docTitle.lastIndexOf(".") + 1).trim().toLowerCase();
config.put("docserviceApiUrl", apiUrl + properties.getProperty("files.docservice.url.api"));
config.put("errorMessage\", errorMessage);
config.put("docTitle", docTitle);
JSONObject responseJson = new JSONObject();
JSONObject documentObject = new JSONObject();
JSONObject editorConfigObject = new JSONObject();
JSONObject userObject = new JSONObject();
JSONObject permObject = new JSONObject();
try {
responseJson.put("type", "desktop");
responseJson.put("width", "100%");
responseJson.put("height", "100%");
responseJson.put("documentType", getDocType(docExt));
responseJson.put("document", documentObject);
documentObject.put("title", docTitle);
documentObject.put("url", fileUrl);
documentObject.put("fileType", docExt);
documentObject.put("key", key);
documentObject.put("permissions", permObject);
permObject.put("edit", false);
responseJson.put("editorConfig", editorConfigObject);
editorConfigObject.put("lang", localeManager.getLocale(user).toLanguageTag());
editorConfigObject.put("mode", "edit");
if (user != null) {
editorConfigObject.put("user", userObject);
userObject.put("id", user.getName());
userObject.put("name", user.getFullName());
}
// AsHtml at the end disables automatic html encoding
config.put("jsonAsHtml", responseJson.toString());
} catch (Exception ex) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
ex.printStackTrace(pw);
String error = ex.toString() + "\n" + sw.toString();
log.error(error);
}
defaults.putAll(config);
return VelocityUtils.getRenderedTemplate("templates/editor.vm", defaults);
}
private String getDocType(String ext) {
if (".doc.docx.docm.dot.dotx.dotm.odt.fodt.ott.rtf.txt.html.htm.mht.pdf.djvu.fb2.epub.xps".indexOf(ext) != -1)
return \"text\";
if (".xls.xlsx.xlsm.xlt.xltx.xltm.ods.fods.ots.csv".indexOf(ext) != -1)
return "spreadsheet";
if (".pps.ppsx.ppsm.ppt.pptx.pptm.pot.potx.potm.odp.fodp.otp".indexOf(ext) != -1)
return "presentation";
return null;
}
}
這將構(gòu)建一個(gè)在頁(yè)面上打開(kāi)編輯器時(shí)所需的 JSON 對(duì)象。請(qǐng)注意,我們會(huì)將 JSON 對(duì)象放入結(jié)尾是 AsHtml 的變量中。其會(huì)禁用自動(dòng) HTML 編碼,所以我們可將其放入模板的 <script> 標(biāo)記內(nèi),如下:
var json = '${jsonAsHtml}';
這樣就能正常工作了。
我們還使用了三個(gè)工具類:AttachmentUtil、UrlManager 以及 DocumentManager。這些類有點(diǎn)超出本文所涵蓋的范圍(除了 AttachmentUtil 與 DocumentManager 中的一部分內(nèi)容,但我們稍后也會(huì)提到),所以如果您對(duì)此感興趣的話,可自行進(jìn)行探索。
最后還有一件事需要我們?nèi)ヌ幚恚簩⑽臋n提供給文檔服務(wù)器。
這里我們?cè)俅蝿?chuàng)建一個(gè) Servlet,其中唯一的方法就是提供文件。
public class OnlyOfficeSaveFileServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private static final Logger log = LogManager.getLogger("onlyoffice.OnlyOfficeSaveFileServlet");
@ComponentImport
private final PluginSettingsFactory pluginSettingsFactory;
private final PluginSettings settings;
@Inject
public OnlyOfficeSaveFileServlet(PluginSettingsFactory pluginSettingsFactory, JwtManager jwtManager) {
this.pluginSettingsFactory = pluginSettingsFactory;
settings = pluginSettingsFactory.createGlobalSettings();
}
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String vkey = request.getParameter("vkey");
log.info("vkey = " + vkey);
String attachmentIdString = DocumentManager.ReadHash(vkey);
Long attachmentId = Long.parseLong(attachmentIdString);
log.info("attachmentId " + attachmentId);
String contentType = AttachmentUtil.getMediaType(attachmentId);
response.setContentType(contentType);
InputStream inputStream = AttachmentUtil.getAttachmentData(attachmentId);
response.setContentLength(inputStream.available());
byte[] buffer = new byte[10240];
OutputStream output = response.getOutputStream();
for (int length = 0; (length = inputStream.read(buffer)) > 0;) {
output.write(buffer, 0, length);
}
}
}
運(yùn)行與測(cè)試
此時(shí),一切準(zhǔn)備工作都已就緒,我們可以對(duì)附件進(jìn)行打開(kāi)并查看。
運(yùn)行 atlas-run 命令以構(gòu)建插件,然后運(yùn)行 Confluence。
或者,您也可以運(yùn)行 atlas-package 來(lái)僅構(gòu)建一個(gè) .jar。
無(wú)論選擇哪種方式,您都應(yīng)該前往“設(shè)置 -> 管理應(yīng)用”中上傳插件并點(diǎn)擊配置。指定文檔服務(wù)器 URL 并測(cè)試插件。
編輯與共同編輯
如需對(duì)文檔進(jìn)行編輯,我們就還需要進(jìn)行一些更改。首先來(lái)看看其工作原理。
這里有個(gè)名為 callbackUrl 的參數(shù)。其應(yīng)該指向一個(gè)從文檔服務(wù)器接收 JSON 數(shù)據(jù)的 Servlet,用于描述文檔編輯的狀態(tài)。在用戶關(guān)閉文檔時(shí),其會(huì)發(fā)出一則消息告知編輯完成,還會(huì)發(fā)送更新后文檔的 URL,便于我們進(jìn)行下載。
您可能也會(huì)想了解一下共同編輯的原理。只要 key 參數(shù)相同,用戶就可以對(duì)同一個(gè)文檔進(jìn)行編輯。在為文件生成 key 時(shí)有兩件重要的事需要考慮。
1. 每個(gè)文檔的 key 都不應(yīng)相同。
2. 文檔中出現(xiàn)變更時(shí) key 也應(yīng)當(dāng)變更。
通常而言,ID + 修改日期的組合能夠勝任這一工作。但也有其不適用的情況。如果您打算實(shí)現(xiàn)強(qiáng)制保存功能,那么就需要生成一個(gè)在編輯全程保持相同的 key。
key 的生成方法位于 AttachmentUtil 與 DocumentManager 中。
讓我們將下面的代碼添加至編輯器 Servlet 中,就放在 fileUrl = urlManager.GetFileUri(attachmentId); 代碼行后方即可:
if (AttachmentUtil.checkAccess(attachmentId, user, true)) {
callbackUrl = urlManager.getCallbackUrl(attachmentId);
}
將其傳遞給 getTemplate 函數(shù):
writer.write(getTemplate(apiUrl, callbackUrl, fileUrl, key, fileName, user, errorMessage));
然后在此處使用:
permObject.put("edit", callbackUrl != null && !callbackUrl.isEmpty());
...
editorConfigObject.put("callbackUrl", callbackUrl);
接著我們需要?jiǎng)?chuàng)建一個(gè)回調(diào)處理器。我們來(lái)修改一下用于提供文件的 OnlyOfficeSaveFileServlet。這一過(guò)程非常簡(jiǎn)單。我們將對(duì)傳入的 JSON 對(duì)象進(jìn)行解析,查看其中的 status 并在有需要時(shí)保存文檔。相關(guān)文檔可在此處查看。
@Override
public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
response.setContentType("text/plain; charset=utf-8");
String vkey = request.getParameter("vkey");
log.info("vkey = " + vkey);
String attachmentIdString = DocumentManager.ReadHash(vkey);
String error = "";
try {
processData(attachmentIdString, request);
} catch (Exception e) {
error = e.getMessage();
}
PrintWriter writer = response.getWriter();
if (error.isEmpty()) {
writer.write("{\"error\":0}");
} else {
response.setStatus(500);
writer.write("{\"error\":1,\"message\":\"" + error + "\"}");
}
log.info("error = " + error);
}
private void processData(String attachmentIdString, HttpServletRequest request) throws Exception {
log.info("attachmentId = " + attachmentIdString);
InputStream requestStream = request.getInputStream();
if (attachmentIdString.isEmpty()) {
throw new IllegalArgumentException("attachmentId is empty");
}
HttpURLConnection connection = null;
try {
Long attachmentId = Long.parseLong(attachmentIdString);
String body = getBody(requestStream);
log.info("body = " + body);
if (body.isEmpty()) {
throw new IllegalArgumentException("requestBody is empty");
}
JSONObject jsonObj = new JSONObject(body);
long status = jsonObj.getLong("status");
log.info("status = " + status);
// MustSave, Corrupted
if (status == 2 || status == 3) {
ConfluenceUser user = null;
JSONArray users = jsonObj.getJSONArray("users");
if (users.length() > 0) {
String userName = users.getString(0);
UserAccessor userAccessor = (UserAccessor) ContainerManager.getComponent("userAccessor");
user = userAccessor.getUserByName(userName);
log.info("user = " + user);
}
if (user == null || !AttachmentUtil.checkAccess(attachmentId, user, true)) {
throw new SecurityException("Try save without access: " + user);
}
String downloadUrl = jsonObj.getString("url");
log.info("downloadUri = " + downloadUrl);
URL url = new URL(downloadUrl);
connection = (HttpURLConnection) url.openConnection();
int size = connection.getContentLength();
log.info("size = " + size);
InputStream stream = connection.getInputStream();
AttachmentUtil.saveAttachment(attachmentId, stream, size, user);
}
} catch (Exception ex) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
ex.printStackTrace(pw);
String error = ex.toString() + "\n" + sw.toString();
log.error(error);
throw ex;
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private String getBody(InputStream stream) {
Scanner scanner = null;
Scanner scannerUseDelimiter = null;
try {
scanner = new Scanner(stream);
scannerUseDelimiter = scanner.useDelimiter("\\A");
return scanner.hasNext() ? scanner.next() : "";
} finally {
scannerUseDelimiter.close();
scanner.close();
}
}
好了,這就 OK 啦。接下來(lái)就是對(duì)項(xiàng)目進(jìn)行重新編譯與測(cè)試了。
安全性
您可能想知道,我們?nèi)绾未_保通過(guò) POST 方式傳遞至 Servlet 的 JSON 確實(shí)來(lái)自文檔服務(wù)器。答案很簡(jiǎn)單:文檔服務(wù)器使用 JWT 來(lái)實(shí)現(xiàn)這一功能。JWT 是很熱門(mén)的話題,也是值得單獨(dú)用一些篇幅來(lái)進(jìn)行介紹的話題,這里我們就不展開(kāi)了。
我們所使用的是 JwtManager 類。基本上而言,JWT 是基于密鑰的哈希加密 JSON。
首先我們需要為密鑰添加一個(gè)新的設(shè)置。這應(yīng)該不難。您可在這里找到代碼:configure.vm 模板、配置 Servlet。
然后我們會(huì)向編輯器 Servlet 構(gòu)造函數(shù)中添加 JwtManager,并在 config.put("jsonAsHtml", responseJson.toString()); 代碼行之前對(duì)其進(jìn)行使用:
if (jwtManager.jwtEnabled()) {
responseJson.put("token", jwtManager.createToken(responseJson));
}
現(xiàn)在我們就取得了文檔服務(wù)器的信任!
為了確保文檔服務(wù)器也能得到我們的信任,我們將在處理回調(diào)時(shí)使用 JWT。
文檔服務(wù)器傳遞 JWT 的方式有兩種:通過(guò) HTTP 標(biāo)頭傳遞,或包含在 JSON 中傳遞,具體取決于配置情況。這里我們將同時(shí)對(duì)兩者進(jìn)行了解。
下面來(lái)修改一下回調(diào) Servlet,在其構(gòu)造函數(shù)中添加 JwtManager,并在 JSONObject jsonObj = new JSONObject(body); 代碼行后使用:
if (jwtManager.jwtEnabled()) {
String token = jsonObj.optString("token");
Boolean inBody = true;
if (token == null || token == "") {
String jwth = (String) settings.get("onlyoffice.jwtHeader");
String header = (String) request.getHeader(jwth == null || jwth.isEmpty() ? "Authorization" : jwth);
token = (header != null && header.startsWith("Bearer ")) ? header.substring(7) : header;
inBody = false;
}
if (token == null || token == "") {
throw new SecurityException("Try save without JWT");
}
if (!jwtManager.verify(token)) {
throw new SecurityException("Try save with wrong JWT");
}
JSONObject bodyFromToken = new JSONObject(
new String(Base64.getUrlDecoder().decode(token.split("\\.")[1]), "UTF-8"));
if (inBody) {
jsonObj = bodyFromToken;
} else {
jsonObj = bodyFromToken.getJSONObject("payload");
}
}
好了,收工!現(xiàn)在我們就能在 JWT 的保護(hù)下查看和編輯文檔了,其將不會(huì)受到未授權(quán)訪問(wèn)的侵?jǐn)_,此外還有便捷的配置項(xiàng)可供使用。