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.ByteArrayOutputStream;
020import java.io.File;
021import java.io.IOException;
022import java.io.InputStream;
023import java.io.OutputStream;
024import java.nio.charset.StandardCharsets;
025import java.nio.file.Files;
026import java.nio.file.Path;
027import java.nio.file.StandardCopyOption;
028import java.util.Base64;
029import java.util.HashMap;
030import java.util.List;
031import java.util.Map;
032import java.util.Set;
033import java.util.concurrent.ConcurrentHashMap;
034import java.util.concurrent.locks.Lock;
035import java.util.concurrent.locks.ReentrantLock;
036
037import org.apache.avalon.framework.component.Component;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.avalon.framework.service.Serviceable;
041import org.apache.commons.io.FileUtils;
042import org.apache.commons.io.IOUtils;
043import org.apache.commons.lang3.StringUtils;
044import org.apache.excalibur.source.SourceResolver;
045import org.apache.excalibur.source.impl.URLSource;
046import org.apache.http.client.config.RequestConfig;
047import org.apache.http.client.methods.CloseableHttpResponse;
048import org.apache.http.client.methods.HttpPost;
049import org.apache.http.entity.StringEntity;
050import org.apache.http.impl.client.CloseableHttpClient;
051import org.apache.http.impl.client.HttpClientBuilder;
052
053import org.ametys.cms.content.indexing.solr.SolrResourceGroupedMimeTypes;
054import org.ametys.core.authentication.token.AuthenticationTokenManager;
055import org.ametys.core.ui.Callable;
056import org.ametys.core.user.CurrentUserProvider;
057import org.ametys.core.user.UserIdentity;
058import org.ametys.core.util.JSONUtils;
059import org.ametys.plugins.explorer.resources.Resource;
060import org.ametys.plugins.repository.AmetysObjectResolver;
061import org.ametys.plugins.workspaces.WorkspacesHelper.FileType;
062import org.ametys.runtime.config.Config;
063import org.ametys.runtime.plugin.component.AbstractLogEnabled;
064import org.ametys.runtime.util.AmetysHomeHelper;
065
066import com.auth0.jwt.JWT;
067import com.auth0.jwt.algorithms.Algorithm;
068
069/**
070 * Main helper for OnlyOffice edition
071 */
072public class OnlyOfficeManager extends AbstractLogEnabled implements Component, Serviceable
073{
074    /** The Avalon role */
075    public static final String ROLE = OnlyOfficeManager.class.getName();
076    
077    /** The path for workspace cache */
078    public static final String WORKSPACE_PATH_CACHE = "cache/workspaces";
079    
080    /** The path for thumbnail file */
081    public static final String THUMBNAIL_FILE_PATH = "file-manager/thumbnail";
082    
083    /** The token manager */
084    protected AuthenticationTokenManager _tokenManager;
085    /** The current user provider */
086    protected CurrentUserProvider _currentUserProvider;
087    /** The Ametys object resolver */
088    protected AmetysObjectResolver _resolver;
089    /** The Only Office key manager */
090    protected OnlyOfficeKeyManager _onlyOfficeKeyManager;
091    /** The JSON utils */
092    protected JSONUtils _jsonUtils;
093    /** The source resolver */
094    protected SourceResolver _sourceResolver;
095    
096    private Map<String, Lock> _locks = new ConcurrentHashMap<>();
097    
098    @Override
099    public void service(ServiceManager manager) throws ServiceException
100    {
101        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
102        _tokenManager = (AuthenticationTokenManager) manager.lookup(AuthenticationTokenManager.ROLE);
103        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
104        _onlyOfficeKeyManager = (OnlyOfficeKeyManager) manager.lookup(OnlyOfficeKeyManager.ROLE);
105        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
106        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
107    }
108    
109    /**
110     * Determines if OnlyOffice edition is available
111     * @return true if OnlyOffice edition is available
112     */
113    public boolean isOnlyOfficeAvailable()
114    {
115        return Config.getInstance().getValue("workspaces.onlyoffice.enabled", false, false);
116    }
117    
118    /**
119     * Get the needed information for Only Office edition
120     * @param resourceId the id of resource to edit
121     * @return the only office informations
122     */
123    @Callable
124    public Map<String, Object> getOnlyOfficeInfo(String resourceId)
125    {
126        Map<String, Object> infos = new HashMap<>();
127        
128        OnlyOfficeResource resource = _getOnlyOfficeResource(resourceId, _currentUserProvider.getUser());
129        
130        Map<String, Object> fileInfo = new HashMap<>();
131        fileInfo.put("title", resource.getTitle());
132        fileInfo.put("fileExtension", resource.getFileExtension());
133        fileInfo.put("key", resource.getKey());
134        fileInfo.put("urlDownload", resource.getURLDownload());
135
136        infos.put("file", fileInfo);
137        infos.put("callbackUrl", resource.getCallbackURL());
138        
139        return infos;
140    }
141    
142    
143    /**
144     * Generate a token for OnlyOffice use
145     * @param fileId id of the resource that will be used by OnlyOffice
146     * @return the token
147     */
148    @Callable
149    public String generateToken(String fileId)
150    {
151        return _generateToken(fileId, _currentUserProvider.getUser());
152    }
153    
154    private String _generateToken(String fileId, UserIdentity user)
155    {
156        Set<String> contexts = Set.of(StringUtils.substringAfter(fileId, "://"));
157        return _tokenManager.generateToken(user, 30000, true, null, contexts, "onlyOfficeResponse", null);
158    }
159    
160    /**
161     * Sign a json configuration for OnlyOffice using a secret parametrized key
162     * @param toSign The json to sign
163     * @return The signed json
164     */
165    @Callable
166    public Map<String, Object> signConfiguration(String toSign)
167    {
168        Map<String, Object> result = new HashMap<>();
169        
170        String token = _signConfiguration(toSign);
171        if (StringUtils.isNotBlank(token))
172        {
173            result.put("signature", token);
174        }
175        
176        result.put("success", "true");
177        return result;
178    }
179    
180    private String _signConfiguration(String toSign)
181    {
182        String secret = Config.getInstance().getValue("workspaces.onlyoffice.secret");
183        
184        if (StringUtils.isNotBlank(secret))
185        {
186            Algorithm algorithm = Algorithm.HMAC256(secret);
187            String token = JWT.create()
188                    .withIssuer(toSign)
189                    .sign(algorithm);
190
191            return token;
192        }
193        
194        return null;
195    }
196    
197    /**
198     * Determines if the resource file can have a preview of thumbnail from only office
199     * @param resourceId the resource id
200     * @return <code>true</code> if resource file can have a preview of thumbnail from only office
201     */
202    public boolean canBePreviewed(String resourceId)
203    {
204        if (!isOnlyOfficeAvailable())
205        {
206            return false;
207        }
208        
209        Resource resource = _resolver.resolveById(resourceId);
210        
211        List<FileType> allowedFileTypes = List.of(
212                FileType.PDF,
213                FileType.PRES,
214                FileType.SPREADSHEET,
215                FileType.TEXT
216        );
217        
218        return SolrResourceGroupedMimeTypes.getGroup(resource.getMimeType())
219            .map(groupMimeType -> allowedFileTypes.contains(FileType.valueOf(groupMimeType.toUpperCase())))
220            .orElse(false);
221    }
222    
223    /**
224     * Generate thumbnail of the resource as png
225     * @param projectName the project name
226     * @param resourceId the resource id
227     * @param user the user generating the thumbnail
228     * @return <code>true</code> is the thumbnail is generated
229     */
230    public boolean generateThumbnailInCache(String projectName, String resourceId, UserIdentity user)
231    {
232        Lock lock = _locks.computeIfAbsent(resourceId, __ -> new ReentrantLock());
233        lock.lock();
234        
235        try
236        {
237            File thumbnailFile = getThumbnailFile(projectName, resourceId);
238            if (thumbnailFile != null && thumbnailFile.exists())
239            {
240                return true;
241            }
242        
243            if (canBePreviewed(resourceId))
244            {
245                String urlPrefix = Config.getInstance().getValue("workspaces.onlyoffice.server.url");
246                String url = StringUtils.stripEnd(urlPrefix, "/") + "/ConvertService.ashx";
247                
248                RequestConfig requestConfig = RequestConfig.custom()
249                        .setConnectTimeout(30000)
250                        .setSocketTimeout(30000)
251                        .build();
252                try (CloseableHttpClient httpclient = HttpClientBuilder.create()
253                                                                       .setDefaultRequestConfig(requestConfig)
254                                                                       .useSystemProperties()
255                                                                       .build())
256                {
257                    // Prepare a request object
258                    HttpPost post = new HttpPost(url);
259                    OnlyOfficeResource resource = _getOnlyOfficeResource(resourceId, user);
260                    
261                    Map<String, Object> thumbnailParameters = new HashMap<>();
262                    thumbnailParameters.put("outputtype", "png");
263                    thumbnailParameters.put("filetype", resource.getFileExtension());
264                    thumbnailParameters.put("key", resource.getKey());
265                    thumbnailParameters.put("url", resource.getURLDownload());
266                    
267                    Map<String, Object> sizeInputs = new HashMap<>();
268                    sizeInputs.put("aspect", 1);
269                    sizeInputs.put("height", 1000);
270                    sizeInputs.put("width", 300);
271                    
272                    thumbnailParameters.put("thumbnail", sizeInputs);
273                    
274                    String jsonBody = _jsonUtils.convertObjectToJson(thumbnailParameters);
275                    StringEntity params = new StringEntity(jsonBody);
276                    post.addHeader("content-type", "application/json");
277                    
278                    String jwtToken = _signConfiguration(jsonBody);
279                    if (jwtToken != null)
280                    {
281                        post.addHeader("Authorization", "Bearer " + jwtToken);
282                    }
283                    
284                    post.setEntity(params);
285                    
286                    try (CloseableHttpResponse httpResponse = httpclient.execute(post))
287                    {
288                        int statusCode = httpResponse.getStatusLine().getStatusCode();
289                        if (statusCode != 200)
290                        {
291                            getLogger().error("An error occurred getting thumbnail for resource id '{}'. HTTP status code response is '{}'", resourceId, statusCode);
292                            return false;
293                        }
294                        
295                        ByteArrayOutputStream bos = new ByteArrayOutputStream();
296                        try (InputStream is = httpResponse.getEntity().getContent())
297                        {
298                            IOUtils.copy(is, bos);
299                        }
300                        
301                        String responseAsStringXML = bos.toString();
302                        if (responseAsStringXML.contains("<Error>"))
303                        {
304                            String errorMsg = StringUtils.substringBefore(StringUtils.substringAfter(responseAsStringXML, "<Error>"), "</Error>");
305                            getLogger().error("An error occurred getting thumbnail for resource id '{}'. Error message is '{}'", resourceId, errorMsg);
306                            return false;
307                        }
308                        else
309                        {
310                            String previewURL = StringUtils.substringBefore(StringUtils.substringAfter(responseAsStringXML, "<FileUrl>"), "</FileUrl>");
311                            String decodeURL = StringUtils.replace(previewURL, "&amp;", "&");
312                            
313                            _generatePNGFileInCache(projectName, decodeURL, resourceId);
314                            
315                            return true;
316                        }
317                    }
318                    catch (Exception e)
319                    {
320                        getLogger().error("Error getting thumbnail for file {}", resource.getTitle(), e);
321                    }
322                }
323                catch (Exception e)
324                {
325                    getLogger().error("Unable to contact Only Office conversion API to get thumbnail.", e);
326                }
327            }
328            
329            return false;
330        }
331        finally
332        {
333            lock.unlock();
334            _locks.remove(resourceId, lock); // possible minor race condition here, with no effect if the thumbnail has been correctly generated
335        }
336    }
337    
338    /**
339     * Delete thumbnail in cache
340     * @param projectName the project name
341     * @param resourceId the resourceId id
342     */
343    public void deleteThumbnailInCache(String projectName, String resourceId)
344    {
345        try
346        {
347            File file = getThumbnailFile(projectName, resourceId);
348            if (file != null && file.exists())
349            {
350                FileUtils.forceDelete(file);
351            }
352        }
353        catch (Exception e) 
354        {
355            getLogger().error("Can delete thumbnail in cache for project name '{}' and resource id '{}'", projectName, resourceId, e);
356        }
357    }
358    
359    /**
360     * Delete project thumbnails in cache
361     * @param projectName the project name
362     */
363    public void deleteProjectThumbnailsInCache(String projectName)
364    {
365        try
366        {
367            File thumbnailDir = new File(AmetysHomeHelper.getAmetysHomeData(), WORKSPACE_PATH_CACHE + "/" + projectName);
368            if (thumbnailDir.exists())
369            {
370                FileUtils.forceDelete(thumbnailDir);
371            }
372        }
373        catch (Exception e) 
374        {
375            getLogger().error("Can delete thumbnails in cache for project name '{}'", projectName, e);
376        }
377    }
378    
379    /**
380     * Generate a png file from the uri
381     * @param projectName the project name
382     * @param uri the uri
383     * @param fileId the id of the file
384     * @throws IOException if an error occurred
385     */
386    protected void _generatePNGFileInCache(String projectName, String uri, String fileId) throws IOException
387    {
388        Path thumbnailDir = AmetysHomeHelper.getAmetysHomeData().toPath().resolve(Path.of(WORKSPACE_PATH_CACHE, projectName, THUMBNAIL_FILE_PATH));
389        Files.createDirectories(thumbnailDir);
390        
391        String name = _encodeFileId(fileId);
392       
393        URLSource source = null;
394        Path tmpFile = thumbnailDir.resolve(name + ".tmp.png");
395        try
396        {
397            // Resolve the export to the appropriate png url.
398            source = (URLSource) _sourceResolver.resolveURI(uri, null, new HashMap<>());
399           
400            // Save the preview image into a temporary file.
401            try (InputStream is = source.getInputStream(); OutputStream os = Files.newOutputStream(tmpFile))
402            {
403                IOUtils.copy(is, os);
404            }
405           
406            // If all went well until now, rename the temporary file 
407            Files.move(tmpFile, tmpFile.resolveSibling(name + ".png"), StandardCopyOption.REPLACE_EXISTING);
408        }
409        catch (Exception e) 
410        {
411            getLogger().error("An error occurred generating png file with uri '{}'", uri, e);
412        }
413        finally
414        {
415            if (source != null)
416            {
417                _sourceResolver.release(source);
418            }
419        }
420    }
421    
422    private OnlyOfficeResource _getOnlyOfficeResource(String resourceId, UserIdentity user)
423    {
424        OnlyOfficeResource onlyOfficeResource = new OnlyOfficeResource();
425        
426        Resource resource = _resolver.resolveById(resourceId);
427        
428        String token = _generateToken(resourceId, user);
429        String tokenCtx = StringUtils.substringAfter(resourceId, "://");
430        
431        onlyOfficeResource.setTitle(resource.getName());
432        onlyOfficeResource.setFileExtension(StringUtils.substringAfterLast(resource.getName(), ".").toLowerCase());
433        onlyOfficeResource.setKey(_onlyOfficeKeyManager.getKey(resourceId));
434        
435        String ooCMSUrl = Config.getInstance().getValue("workspaces.onlyoffice.bo.url");
436        if (StringUtils.isEmpty(ooCMSUrl))
437        {
438            ooCMSUrl = Config.getInstance().getValue("cms.url");
439        }
440        
441        String downloadUrl = ooCMSUrl
442                + "/_workspaces/only-office/download-resource?"
443                + "id=" + resourceId
444                + "&token=" + token
445                + "&tokenContext=" + tokenCtx;
446        onlyOfficeResource.setURLDownload(downloadUrl);
447        
448        String callbackUrl = ooCMSUrl
449                + "/_workspaces/only-office/response.json?"
450                + "id=" + resourceId
451                + "&token=" + token
452                + "&tokenContext=" + tokenCtx;
453        onlyOfficeResource.setCallbackUR(callbackUrl);
454        
455        return onlyOfficeResource;
456    }
457    
458    /**
459     * Get thumbnail file
460     * @param projectName the project name
461     * @param resourceId the resource id
462     * @return the thumbnail file. Can be <code>null</code> if doesn't exist.
463     */
464    public File getThumbnailFile(String projectName, String resourceId)
465    {
466        File thumbnailDir = new File(AmetysHomeHelper.getAmetysHomeData(), WORKSPACE_PATH_CACHE + "/" + projectName + "/" + THUMBNAIL_FILE_PATH);
467        if (thumbnailDir.exists())
468        {
469            String name = _encodeFileId(resourceId);
470            return new File(thumbnailDir, name + ".png");
471        }
472        
473        return null;
474    }
475    
476    private String _encodeFileId(String fileId)
477    {
478        return Base64.getEncoder().withoutPadding().encodeToString(fileId.getBytes(StandardCharsets.UTF_8));
479    }
480    
481    private static class OnlyOfficeResource
482    {
483        private String _title;
484        private String _fileExtension;
485        private String _key;
486        private String _urlDownload;
487        private String _callbackUrl;
488        
489        public OnlyOfficeResource()
490        {
491            // default constructor
492        }
493        
494        public String getTitle()
495        {
496            return _title;
497        }
498        
499        public void setTitle(String title)
500        {
501            _title = title;
502        }
503        
504        public String getFileExtension()
505        {
506            return _fileExtension;
507        }
508        
509        public void setFileExtension(String fileExtension)
510        {
511            _fileExtension = fileExtension;
512        }
513        
514        public String getKey()
515        {
516            return _key;
517        }
518        
519        public void setKey(String key)
520        {
521            _key = key;
522        }
523        
524        public String getURLDownload()
525        {
526            return _urlDownload;
527        }
528        
529        public void setURLDownload(String urlDownload)
530        {
531            _urlDownload = urlDownload;
532        }
533        
534        public String getCallbackURL()
535        {
536            return _callbackUrl;
537        }
538        
539        public void setCallbackUR(String callbackUrl)
540        {
541            _callbackUrl = callbackUrl;
542        }
543    }
544}