如何將在線文檔編輯器集成至 Confluence 管理平臺(tái)

了解如何為 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è)工具類:AttachmentUtilUrlManager 以及 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 的生成方法位于 AttachmentUtilDocumentManager 中。

讓我們將下面的代碼添加至編輯器 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)可供使用。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容