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