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