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