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, "&", "&"); 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}