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