001/*
002 *  Copyright 2020 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016
017package org.ametys.plugins.workspaces.documents.onlyoffice;
018
019import java.io.IOException;
020import java.io.InputStream;
021import java.net.URL;
022import java.nio.charset.StandardCharsets;
023import java.util.HashMap;
024import java.util.List;
025import java.util.Map;
026
027import javax.servlet.http.HttpServletRequest;
028
029import org.apache.avalon.framework.parameters.Parameters;
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.cocoon.acting.ServiceableAction;
033import org.apache.cocoon.environment.ObjectModelHelper;
034import org.apache.cocoon.environment.Redirector;
035import org.apache.cocoon.environment.Request;
036import org.apache.cocoon.environment.SourceResolver;
037import org.apache.cocoon.environment.http.HttpEnvironment;
038import org.apache.commons.io.IOUtils;
039import org.apache.commons.lang3.StringUtils;
040import org.slf4j.Logger;
041import org.slf4j.LoggerFactory;
042
043import org.ametys.core.cocoon.JSonReader;
044import org.ametys.core.util.JSONUtils;
045import org.ametys.plugins.explorer.resources.Resource;
046import org.ametys.plugins.repository.AmetysObjectResolver;
047import org.ametys.plugins.workspaces.documents.WorkspaceExplorerResourceDAO;
048
049/**
050 * Action to handle OnlyOffice communications
051 */
052public class GetOnlyOfficeResponse extends ServiceableAction
053{
054    /**
055     * Enumeration for user connection status    
056     */
057    public enum UserStatus 
058    {
059        /** Unknown */
060        UNKNOWN,
061        /** User connection */
062        CONNECTION,
063        /** User disconnection */
064        DISCONNECTION
065    }
066
067    /** The logger */
068    protected Logger _logger = LoggerFactory.getLogger(GetOnlyOfficeResponse.class.getName());
069    
070    /**  JSON utils */
071    protected JSONUtils _jsonUtils;
072    
073    /** The Ametys object resolver */
074    protected AmetysObjectResolver _resolver;
075    
076    /** Workspace explorer resource DAO */
077    protected WorkspaceExplorerResourceDAO _workspaceExplorerResourceDAO;
078
079    /** The only office manager */
080    protected OnlyOfficeKeyManager _onlyOfficeManager;
081    
082    @Override
083    public void service(ServiceManager smanager) throws ServiceException
084    {
085        super.service(smanager);
086        _jsonUtils = (JSONUtils) smanager.lookup(JSONUtils.ROLE);
087        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
088        _workspaceExplorerResourceDAO = (WorkspaceExplorerResourceDAO) smanager.lookup(WorkspaceExplorerResourceDAO.ROLE);
089        _onlyOfficeManager = (OnlyOfficeKeyManager) smanager.lookup(OnlyOfficeKeyManager.ROLE);
090    }
091    
092    @Override
093    public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
094    {
095        Map<String, Object> result = new HashMap<>();
096        
097        Request request = ObjectModelHelper.getRequest(objectModel);
098        
099        String resourceId = request.getParameter("id");
100        
101        HttpServletRequest httpRequest = (HttpServletRequest) objectModel.get(HttpEnvironment.HTTP_REQUEST_OBJECT);
102        
103        try (InputStream is = httpRequest.getInputStream())
104        {
105            String body = IOUtils.toString(is, StandardCharsets.UTF_8);
106            Map<String, Object> jsonMap = _jsonUtils.convertJsonToMap(body);
107            boolean success = callbackHandler(jsonMap, resourceId);
108            
109            // Expected response from the document storage service is { "error": 0 }, otherwise the document editor will display an error message
110            result.put("error", success ? 0 : 1);
111        }
112        
113        request.setAttribute(JSonReader.OBJECT_TO_READ, result);
114        return EMPTY_MAP;
115    }
116    
117    /**
118     * Analyze the response from document editing service on OnlyOffice Server
119     * See https://api.onlyoffice.com/editors/callback
120     * @param response the response as JSON object
121     * @param resourceId id of edited resource
122     * @return true if the response is ok and the needed process successed.
123     */
124    protected boolean callbackHandler(Map<String, Object> response, String resourceId)
125    {
126        // edited document identifier
127        String key = (String) response.get("key");
128        if (key == null)
129        {
130            _logger.error("The edited document identifier is missing in document editing service's response");
131            return false;
132        }
133        
134        // status of document
135        Object oStatus = response.get("status");
136        if (oStatus == null)
137        {
138            _logger.error("The status of the edited document is missing in document editing service's response");
139            return false;
140        }
141        
142        int status = (Integer) oStatus;
143        
144        _logger.debug("Receive status {} for key '{}' and resource '{}' from document editing service", status, key, resourceId);
145        
146        switch (status)
147        {
148            case 0:
149                // document not found ?? (0 is not part of documented status)
150                return true;
151            case 1:
152                // document is being edited
153                return getUserConnectionStatus(response) != UserStatus.UNKNOWN;
154            case 2:
155                // document is ready for saving
156                // Status 2 is received 10s after the document is closed for editing with the identifier of the user who was the last to send the changes to the document editing service
157                try
158                {
159                    Boolean notModified = (Boolean) response.get("notmodified");
160                    
161                    if (notModified == null || !notModified)
162                    {
163                        return saveDocument(response, resourceId);
164                    }
165                    
166                    return true;
167                }
168                finally
169                {
170                    _onlyOfficeManager.removeKey(resourceId);
171                }
172            case 3:
173                // document saving error has occurred
174                _logger.error("Document editing service returned that document saving error has occurred (3)");
175                _onlyOfficeManager.removeKey(resourceId);
176                return false;
177            case 4:
178                // document is closed with no changes
179                _logger.info("Document editing service returned that document was closed with no changes (4)");
180                _onlyOfficeManager.removeKey(resourceId);
181                return getUserConnectionStatus(response) != UserStatus.UNKNOWN;
182            case 6:
183                // document is being edited, but the current document state is saved (force save)
184                return saveDocument(response, resourceId);
185            case 7:
186                // error has occurred while force saving the document
187                _logger.error("Document editing service returned that an error has occurred while force saving the document (7)");
188                return false;
189            default:
190                _logger.error("Document editing service returned an invalid status: " + status);
191                return false;
192        }
193    }
194    
195    /**
196     * Get the status of a user connection
197     * @param response response of OO server as JSON
198     * @return the user status
199     */
200    protected UserStatus getUserConnectionStatus(Map<String, Object> response)
201    {
202        if (response != null && response.containsKey("actions"))
203        {
204            @SuppressWarnings("unchecked")
205            List<Map<String, Object>> actions = (List<Map<String, Object>>) response.get("actions");
206            
207            for (Map<String, Object> action : actions)
208            {
209                int type = (Integer) action.get("type");
210                return type == 1 ? UserStatus.CONNECTION : UserStatus.DISCONNECTION;
211            }
212        }
213
214        return UserStatus.UNKNOWN;
215    }
216    
217    /**
218     * Save the document if needed
219     * @param response the OO response as JSON
220     * @param resourceId id of document to edit
221     * @return true if save success, false otherwise
222     */
223    protected boolean saveDocument(Map<String, Object> response, String resourceId)
224    {
225        if (response.get("notmodified") != null && (boolean) response.get("notmodified"))
226        {
227            // No save is needed
228            _logger.debug("No modification was detected by document editing service");
229            return true;
230        }
231        
232        String url = (String) response.get("url");
233        if (StringUtils.isEmpty(url))
234        {
235            _logger.error("Unable to save document, the document url is missing");
236            return false;
237        }
238        
239        return processSave(url, resourceId);
240    }
241
242    /**
243     * Save the document
244     * @param documentURI the URI of document to save
245     * @param resourceId id of the resource to update
246     * @return true if success to save document, false otherwise
247     */
248    protected boolean processSave(String documentURI, String resourceId)
249    {
250        try
251        {
252            URL url = new URL(documentURI);
253            
254            Resource resource = _resolver.resolveById(resourceId);
255            
256            try (InputStream is = url.openStream())
257            {
258                // Update the document
259                Map<String, Object> result = _workspaceExplorerResourceDAO.addFile(is, resource.getName(), resource.getParent().getId(), false /* unarchive */, false /* allow rename */, true /* allow update */);
260                if (result != null && result.containsKey("resources"))
261                {
262                    return true;
263                }
264                else
265                {
266                    _logger.error("Failed to update document with id '{}", resourceId);
267                    return false;
268                }
269            }
270        }
271        catch (IOException e)
272        {
273            _logger.error("Unable to get document at uri {}", documentURI, e);
274            return false;
275        }
276        catch (IllegalAccessException e) 
277        {
278            _logger.error("User has no sufficient right to edit the document with id '{}'", resourceId, e);
279            return false;
280        }
281    }
282}