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