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