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