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.right.RightManager;
045import org.ametys.core.right.RightManager.RightResult;
046import org.ametys.core.user.CurrentUserProvider;
047import org.ametys.core.util.JSONUtils;
048import org.ametys.plugins.explorer.resources.Resource;
049import org.ametys.plugins.explorer.resources.actions.ExplorerResourcesDAO;
050import org.ametys.plugins.repository.AmetysObjectResolver;
051import org.ametys.plugins.workspaces.documents.WorkspaceExplorerResourceDAO;
052
053/**
054 * Action to handle OnlyOffice communications
055 */
056public class GetOnlyOfficeResponse extends ServiceableAction
057{
058    /**
059     * Enumeration for user connection status    
060     */
061    public enum UserStatus 
062    {
063        /** Unknown */
064        UNKNOWN,
065        /** User connection */
066        CONNECTION,
067        /** User disconnection */
068        DISCONNECTION
069    }
070
071    /** The logger */
072    protected Logger _logger = LoggerFactory.getLogger(GetOnlyOfficeResponse.class.getName());
073    
074    /**  JSON utils */
075    protected JSONUtils _jsonUtils;
076    
077    /** The Ametys object resolver */
078    protected AmetysObjectResolver _resolver;
079    
080    /** Workspace explorer resource DAO */
081    protected WorkspaceExplorerResourceDAO _workspaceExplorerResourceDAO;
082
083    /** The only office manager */
084    protected OnlyOfficeKeyManager _onlyOfficeManager;
085    
086    /** The current user provider */
087    protected CurrentUserProvider _currentUserProvider;
088    
089    /** The right manager */
090    protected RightManager _rightManager;
091    
092    @Override
093    public void service(ServiceManager smanager) throws ServiceException
094    {
095        super.service(smanager);
096        _jsonUtils = (JSONUtils) smanager.lookup(JSONUtils.ROLE);
097        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
098        _workspaceExplorerResourceDAO = (WorkspaceExplorerResourceDAO) smanager.lookup(WorkspaceExplorerResourceDAO.ROLE);
099        _onlyOfficeManager = (OnlyOfficeKeyManager) smanager.lookup(OnlyOfficeKeyManager.ROLE);
100        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
101        _rightManager = (RightManager) smanager.lookup(RightManager.ROLE);
102    }
103    
104    @Override
105    public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
106    {
107        Map<String, Object> result = new HashMap<>();
108        
109        Request request = ObjectModelHelper.getRequest(objectModel);
110        
111        String resourceId = request.getParameter("id");
112        
113        HttpServletRequest httpRequest = (HttpServletRequest) objectModel.get(HttpEnvironment.HTTP_REQUEST_OBJECT);
114        
115        try (InputStream is = httpRequest.getInputStream())
116        {
117            String body = IOUtils.toString(is, StandardCharsets.UTF_8);
118            Map<String, Object> jsonMap = _jsonUtils.convertJsonToMap(body);
119            boolean success = callbackHandler(jsonMap, resourceId);
120            
121            // Expected response from the document storage service is { "error": 0 }, otherwise the document editor will display an error message
122            result.put("error", success ? 0 : 1);
123        }
124        
125        request.setAttribute(JSonReader.OBJECT_TO_READ, result);
126        return EMPTY_MAP;
127    }
128    
129    /**
130     * Analyze the response from document editing service on OnlyOffice Server
131     * See https://api.onlyoffice.com/editors/callback
132     * @param response the response as JSON object
133     * @param resourceId id of edited resource
134     * @return true if the response is ok and the needed process successed.
135     */
136    protected boolean callbackHandler(Map<String, Object> response, String resourceId)
137    {
138        // edited document identifier
139        String key = (String) response.get("key");
140        if (key == null)
141        {
142            _logger.error("The edited document identifier is missing in document editing service's response");
143            return false;
144        }
145        
146        // status of document
147        Object oStatus = response.get("status");
148        if (oStatus == null)
149        {
150            _logger.error("The status of the edited document is missing in document editing service's response");
151            return false;
152        }
153        
154        int status = (Integer) oStatus;
155        
156        _logger.debug("Receive status {} for key '{}' and resource '{}' from document editing service", status, key, resourceId);
157        
158        switch (status)
159        {
160            case 0:
161                // document not found ?? (0 is not part of documented status)
162                return true;
163            case 1:
164                // document is being edited
165                return getUserConnectionStatus(response) != UserStatus.UNKNOWN;
166            case 2:
167                // document is ready for saving
168                // 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
169                try
170                {
171                    Boolean notModified = (Boolean) response.get("notmodified");
172                    
173                    if (notModified == null || !notModified)
174                    {
175                        return saveDocument(response, resourceId);
176                    }
177                    
178                    return true;
179                }
180                finally
181                {
182                    _onlyOfficeManager.removeKey(resourceId);
183                }
184            case 3:
185                // document saving error has occurred
186                _logger.error("Document editing service returned that document saving error has occurred (3)");
187                _onlyOfficeManager.removeKey(resourceId);
188                return false;
189            case 4:
190                // document is closed with no changes
191                _logger.info("Document editing service returned that document was closed with no changes (4)");
192                _onlyOfficeManager.removeKey(resourceId);
193                return getUserConnectionStatus(response) != UserStatus.UNKNOWN;
194            case 6:
195                // document is being edited, but the current document state is saved (force save)
196                return saveDocument(response, resourceId);
197            case 7:
198                // error has occurred while force saving the document
199                _logger.error("Document editing service returned that an error has occurred while force saving the document (7)");
200                return false;
201            default:
202                _logger.error("Document editing service returned an invalid status: " + status);
203                return false;
204        }
205    }
206    
207    /**
208     * Get the status of a user connection
209     * @param response response of OO server as JSON
210     * @return the user status
211     */
212    protected UserStatus getUserConnectionStatus(Map<String, Object> response)
213    {
214        if (response != null && response.containsKey("actions"))
215        {
216            @SuppressWarnings("unchecked")
217            List<Map<String, Object>> actions = (List<Map<String, Object>>) response.get("actions");
218            
219            for (Map<String, Object> action : actions)
220            {
221                int type = (Integer) action.get("type");
222                return type == 1 ? UserStatus.CONNECTION : UserStatus.DISCONNECTION;
223            }
224        }
225
226        return UserStatus.UNKNOWN;
227    }
228    
229    /**
230     * Save the document if needed
231     * @param response the OO response as JSON
232     * @param resourceId id of document to edit
233     * @return true if save success, false otherwise
234     */
235    protected boolean saveDocument(Map<String, Object> response, String resourceId)
236    {
237        if (response.get("notmodified") != null && (boolean) response.get("notmodified"))
238        {
239            // No save is needed
240            _logger.debug("No modification was detected by document editing service");
241            return true;
242        }
243        
244        String url = (String) response.get("url");
245        if (StringUtils.isEmpty(url))
246        {
247            _logger.error("Unable to save document, the document url is missing");
248            return false;
249        }
250        
251        return processSave(url, resourceId);
252    }
253
254    /**
255     * Save the document
256     * @param documentURI the URI of document to save
257     * @param resourceId id of the resource to update
258     * @return true if success to save document, false otherwise
259     */
260    protected boolean processSave(String documentURI, String resourceId)
261    {
262        try
263        {
264            URL url = new URL(documentURI);
265            
266            Resource resource = _resolver.resolveById(resourceId);
267            if (_rightManager.hasRight(_currentUserProvider.getUser(), ExplorerResourcesDAO.RIGHTS_RESOURCE_ADD, resource.getParent()) != RightResult.RIGHT_ALLOW)
268            {
269                _logger.error("User '" + _currentUserProvider.getUser() + "' tried to add file without convenient right [" + ExplorerResourcesDAO.RIGHTS_RESOURCE_ADD + "]");
270                return false;
271            }
272            
273            try (InputStream is = url.openStream())
274            {
275                // Update the document
276                Map<String, Object> result = _workspaceExplorerResourceDAO.addFile(is, resource.getName(), resource.getParent().getId(), false /* unarchive */, false /* allow rename */, true /* allow update */);
277                if (result != null && result.containsKey("resources"))
278                {
279                    return true;
280                }
281                else
282                {
283                    _logger.error("Failed to update document with id '{}", resourceId);
284                    return false;
285                }
286            }
287        }
288        catch (IOException e)
289        {
290            _logger.error("Unable to get document at uri {}", documentURI, e);
291            return false;
292        }
293    }
294}