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