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